Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
J
jj_video_practice
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
jj_video_practice
Commits
8aa270b1
Commit
8aa270b1
authored
Dec 23, 2025
by
kingweight
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
视频帧轨道
parent
da662a60
Changes
2
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
390 additions
and
134 deletions
+390
-134
form.component.html
src/app/form/form.component.html
+10
-97
form.component.ts
src/app/form/form.component.ts
+380
-37
No files found.
src/app/form/form.component.html
View file @
8aa270b1
...
@@ -2,17 +2,10 @@
...
@@ -2,17 +2,10 @@
<div
class=
"model-content"
>
<div
class=
"model-content"
>
<!-- <div
id="drop-area"
(dragover)="onDragOver($event)"
(drop)="onDrop($event)"
>
拖拽文件到此区域进行批量上传
</div> -->
<div
style=
"width: 100%; display: inline-flex;flex-direction: row;flex-wrap: wrap;"
>
<div
style=
"width: 100%; display: inline-flex;flex-direction: row;flex-wrap: wrap;"
>
<div
style=
"width: 50% ; margin-left: 2.5%; min-width: 300px; max-width: 650px; margin-top: 20px;border: 2px dashed;padding: 20px 10px 5px 10px;"
<div
style=
"width: 50% ; margin-left: 2.5%; min-width: 300px; max-width: 650px; margin-top: 20px;border: 2px dashed;padding: 20px 10px 5px 10px;"
align=
'center'
>
align=
'center'
>
<div
style=
"width: 100px; background-color: white; transform: translateY(-30px);"
>
视频
</div>
<div
style=
"width: 100px; background-color: white; transform: translateY(-30px);"
>
视频
互动课信息
</div>
<div
style=
"margin: 20px; display: flex; align-items: center; "
>
<div
style=
"margin: 20px; display: flex; align-items: center; "
>
<h3
style=
"width: 120px;"
>
单元名称:
</h3>
<h3
style=
"width: 120px;"
>
单元名称:
</h3>
...
@@ -27,11 +20,10 @@
...
@@ -27,11 +20,10 @@
<app-upload-image-with-preview
style=
"width: 80%;"
[
picUrl
]="
item
.
cover
"
(
imageUploaded
)="
onImageUploadSuccess
($
event
)"
>
<app-upload-image-with-preview
style=
"width: 80%;"
[
picUrl
]="
item
.
cover
"
(
imageUploaded
)="
onImageUploadSuccess
($
event
)"
>
</app-upload-image-with-preview>
</app-upload-image-with-preview>
<app-upload-video
[
videoUrl
]="
item
.
video_url
.
replace
('.
mp4
',
'
_h
.
mp4
')"
[
showUploadBtn
]="
true
"
(
videoUploaded
)="
onVideoUploaded
($
event
)"
(
onCapture
)="
handleOnCapture
($
event
)"
(
parentRef
)="
onParentRef
($
event
)"
></app-upload-video>
<app-upload-video
[
videoUrl
]="
item
.
video_url
.
replace
('.
mp4
',
'
_h
.
mp4
')"
[
showUploadBtn
]="
true
"
(
videoUploaded
)="
onVideoUploaded
($
event
)"
(
onCapture
)="
handleOnCapture
($
event
)"
(
parentRef
)="
onParentRef
($
event
)"
></app-upload-video>
<div
style=
"padding: 5px 0;"
align=
'center'
>
<
!-- <
div style="padding: 5px 0;" align='center'>
<h3>视频地址</h3>
<h3>视频地址</h3>
<input type="text" nz-input [(ngModel)]="item.video_url" disabled (blur)="save()">
<input type="text" nz-input [(ngModel)]="item.video_url" disabled (blur)="save()">
</div>
</div> -->
<!-- <button style="margin-top: 10px; " (click)="onRemoveVideo()">删除视频</button> -->
</div>
</div>
</div>
</div>
</div>
</div>
...
@@ -210,88 +202,6 @@
...
@@ -210,88 +202,6 @@
</div>
</div>
<!-- <div style="margin-top:10px; border: 1px #ccc solid; border-radius: 15px; padding: 15px; background-color: rgb(248, 255, 245);">
<h3>设置选项:</h3>
<div style="margin: 10px;">
<div style="display: flex; align-items: center; justify-content:flex-start;">
<span style="margin-right: 20px;">选项类型:</span>
<label style="" nz-checkbox [(ngModel)]="ques.optionTypeText" (ngModelChange)="checkboxChange($event, ques, 'optionTypeText')">文本</label>
<label style="" nz-checkbox [(ngModel)]="ques.optionTypeAudio" (ngModelChange)="checkboxChange($event, ques, 'optionTypeAudio')">音频</label>
<label style="" nz-checkbox [(ngModel)]="ques.optionTypePic" (ngModelChange)="checkboxChange($event, ques, 'optionTypePic')">图片</label>
</div>
</div>
<div *ngFor="let option of ques.optionArr; let j = index"
style="border: 1px #ccc solid; border-radius: 15px; width: 100%; padding: 30px; ">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center;">
<h4>设置选项-{{j+1}}:</h4>
<div align="center" style="display: flex; align-items: center; justify-content: center;margin-top: -7px;">
<nz-radio-group [ngModel]="option.answer" (ngModelChange)="radioChange($event, option, 'answer')">
<div style="display: flex; flex-wrap: wrap; width: 100%;">
<label style="margin-left: 5px;" nz-radio [nzValue]="'isRight'">{{'正确'}}</label>
<label style="margin-left: 5px;" nz-radio [nzValue]="'isWrong'">{{'错误'}}</label>
</div>
</nz-radio-group>
</div>
</div>
<button style="float:right" nz-button nzType="danger" (click)="deleteOptionBtnClick(ques.optionArr, j)">
x
</button>
</div>
<div *ngIf="ques.optionTypeText" style="margin-top: 5px; display: flex; align-items: center; ">
<span style="">文本:</span>
<input type="text" nz-input [(ngModel)]="option.text" (blur)="save()" style=" width: 200px;">
</div>
<div *ngIf="ques.optionTypeAudio" style="margin-top: 10px; display: flex; align-items: center;">
<span style="">音频:</span>
<app-audio-recorder [audioUrl]="option.audio_url"
(audioUploaded)="onItemAudioUploadSuccess($event, option, 'audio_url')">
</app-audio-recorder>
</div>
<div *ngIf="ques.optionTypePic" style="margin-top: 10px; display: flex; ">
<span style="">图片:</span>
<div style="width: 300px;">
<app-upload-image-with-preview [picUrl]="option.pic_url"
(imageUploaded)="onItemImgUploadSuccess($event, option, 'pic_url')">
</app-upload-image-with-preview>
</div>
<button *ngIf="option.pic_url" style="float: right;" nz-button nzType="" (click)="onDeletaImg(option, 'pic_url')">
x
</button>
</div>
</div>
</div> -->
<!-- <div style="margin-top: 30px">
<button nz-button nzType="dashed" (click)="addOneOption(ques)" style="width: 150px; height: 50px;">
<i nz-icon nzType="plus-circle" nzTheme="outline"></i>
添加选项
</button>
</div> -->
</div>
</div>
<div
style=
"padding: 30px;"
>
<div
style=
"padding: 30px;"
>
...
@@ -315,11 +225,13 @@
...
@@ -315,11 +225,13 @@
<div
style=
"margin-bottom: 20px; text-align: center;"
>
<div
style=
"margin-bottom: 20px; text-align: center;"
>
<video
<video
id=
"fragment-video"
id=
"fragment-video"
[
src
]="
fragmentVideoUrl
"
[
src
]="
item
.
video_url
.
replace
('.
mp4
',
'
_h
.
mp4
')"
crossorigin=
"anonymous"
controls
controls
style=
"width: 100%; max-width: 800px;"
style=
"width: 100%; max-width: 800px;"
(
loadedmetadata
)="
onFragmentVideoLoadedMetadata
($
event
)"
(
loadedmetadata
)="
onFragmentVideoLoadedMetadata
($
event
)"
(
timeupdate
)="
onFragmentVideoTimeUpdate
($
event
)"
>
(
timeupdate
)="
onFragmentVideoTimeUpdate
($
event
)"
(
error
)="
onFragmentVideoError
($
event
)"
>
您的浏览器不支持视频播放
您的浏览器不支持视频播放
</video>
</video>
</div>
</div>
...
@@ -332,10 +244,11 @@
...
@@ -332,10 +244,11 @@
</div>
</div>
<!-- 视频帧缩略图轨道 -->
<!-- 视频帧缩略图轨道 -->
<div
*
ngIf=
"videoThumbnails.length > 0"
style=
"margin-bottom:
1
5px;"
>
<div
*
ngIf=
"videoThumbnails.length > 0"
style=
"margin-bottom:
4
5px;"
>
<div
style=
"display: flex; overflow-x: auto; padding: 10px 0; border: 1px solid #e8e8e8; border-radius: 4px; background-color: #fafafa;"
>
<div
#
thumbnailContainer
style=
"display: flex; overflow-x: auto; padding: 10px 0; border: 1px solid #e8e8e8; border-radius: 4px; background-color: #fafafa;"
>
<div
<div
*
ngFor=
"let thumbnail of videoThumbnails; let i = index"
*
ngFor=
"let thumbnail of videoThumbnails; let i = index"
[
id
]="'
thumbnail-
'
+
i
"
(
click
)="
onThumbnailClick
(
getThumbnailTime
(
i
))"
(
click
)="
onThumbnailClick
(
getThumbnailTime
(
i
))"
[
style
.
border
]="
isThumbnailActive
(
i
)
?
'
2px
solid
#
1890ff
'
:
'
2px
solid
transparent
'"
[
style
.
border
]="
isThumbnailActive
(
i
)
?
'
2px
solid
#
1890ff
'
:
'
2px
solid
transparent
'"
style=
"flex-shrink: 0; margin: 0 2px; cursor: pointer; border-radius: 2px; transition: border 0.2s;"
>
style=
"flex-shrink: 0; margin: 0 2px; cursor: pointer; border-radius: 2px; transition: border 0.2s;"
>
...
...
src/app/form/form.component.ts
View file @
8aa270b1
import
{
Component
,
EventEmitter
,
Input
,
OnDestroy
,
OnChanges
,
OnInit
,
Output
,
ApplicationRef
,
ChangeDetectorRef
}
from
'
@angular/core
'
;
import
{
Component
,
EventEmitter
,
Input
,
OnDestroy
,
OnChanges
,
OnInit
,
Output
,
ApplicationRef
,
ChangeDetectorRef
,
ViewChild
,
ElementRef
}
from
'
@angular/core
'
;
import
{
JsonPipe
}
from
'
@angular/common
'
;
import
{
JsonPipe
}
from
'
@angular/common
'
;
import
{
NzMessageService
}
from
'
ng-zorro-antd/message
'
;
import
{
NzMessageService
}
from
'
ng-zorro-antd/message
'
;
import
{
UploadChangeParam
,
UploadXHRArgs
}
from
'
ng-zorro-antd/upload
'
;
import
{
UploadChangeParam
,
UploadXHRArgs
}
from
'
ng-zorro-antd/upload
'
;
...
@@ -14,6 +14,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -14,6 +14,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
// 储存数据用
// 储存数据用
saveKey
=
"
JJ_Vedio_Practice
"
;
saveKey
=
"
JJ_Vedio_Practice
"
;
thumbnailCacheKey
=
"
JJ_Video_Thumbnail_Cache
"
;
// localStorage 缓存键名
baseData
;
baseData
;
...
@@ -23,6 +24,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -23,6 +24,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
cover
:
""
,
cover
:
""
,
title
:
""
,
title
:
""
,
video_title
:
""
,
video_title
:
""
,
quesArr
:
[]
};
};
//临时储存对象
//临时储存对象
tempItem
;
tempItem
;
...
@@ -51,6 +53,8 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -51,6 +53,8 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
videoThumbnails
:
string
[]
=
[];
// 视频帧缩略图数组
videoThumbnails
:
string
[]
=
[];
// 视频帧缩略图数组
isGeneratingThumbnails
=
false
;
// 是否正在生成缩略图
isGeneratingThumbnails
=
false
;
// 是否正在生成缩略图
thumbnailCount
=
20
;
// 生成的缩略图数量
thumbnailCount
=
20
;
// 生成的缩略图数量
thumbnailCache
:
Map
<
string
,
{
thumbnails
:
string
[],
duration
:
number
,
count
:
number
}
>
=
new
Map
();
// 缩略图缓存
@
ViewChild
(
'
thumbnailContainer
'
,
{
static
:
false
})
thumbnailContainer
:
ElementRef
;
// 缩略图容器引用
...
@@ -142,6 +146,9 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -142,6 +146,9 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
ngOnInit
()
{
ngOnInit
()
{
// 从 localStorage 加载缩略图缓存
this
.
loadThumbnailCacheFromLocalStorage
();
// 获取存储的数据
// 获取存储的数据
(
<
any
>
window
).
courseware
.
getData
((
data
)
=>
{
(
<
any
>
window
).
courseware
.
getData
((
data
)
=>
{
...
@@ -167,20 +174,53 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -167,20 +174,53 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
this
.
currentFragmentQues
=
ques
;
this
.
currentFragmentQues
=
ques
;
this
.
currentFragmentIndex
=
i
;
this
.
currentFragmentIndex
=
i
;
this
.
fragmentVideoUrl
=
this
.
item
.
video_url
||
''
;
this
.
fragmentVideoUrl
=
this
.
item
.
video_url
||
''
;
const
videoUrl
=
this
.
item
.
video_url
?
this
.
item
.
video_url
.
replace
(
'
.mp4
'
,
'
_h.mp4
'
)
:
''
;
this
.
fragmentVideoCurrentTime
=
ques
.
time
||
0
;
this
.
fragmentVideoCurrentTime
=
ques
.
time
||
0
;
this
.
fragmentSelectedTime
=
ques
.
time
||
0
;
this
.
fragmentSelectedTime
=
ques
.
time
||
0
;
this
.
videoThumbnails
=
[];
this
.
videoThumbnails
=
[];
this
.
isFragmentModalVisible
=
true
;
this
.
isFragmentModalVisible
=
true
;
// 等待弹窗渲染后获取视频元素并生成缩略图
// 检查缓存
const
cachedData
=
this
.
thumbnailCache
.
get
(
videoUrl
);
if
(
cachedData
)
{
console
.
log
(
'
使用缓存的缩略图
'
);
this
.
videoThumbnails
=
cachedData
.
thumbnails
;
this
.
fragmentVideoDuration
=
cachedData
.
duration
;
this
.
thumbnailCount
=
cachedData
.
count
;
this
.
changeDetectorRef
.
detectChanges
();
}
// 等待弹窗渲染后获取视频元素
setTimeout
(()
=>
{
setTimeout
(()
=>
{
this
.
fragmentVideoElement
=
document
.
getElementById
(
'
fragment-video
'
)
as
HTMLVideoElement
;
this
.
fragmentVideoElement
=
document
.
getElementById
(
'
fragment-video
'
)
as
HTMLVideoElement
;
if
(
this
.
fragmentVideoElement
)
{
if
(
this
.
fragmentVideoElement
)
{
// 确保设置 crossOrigin 属性(必须在设置 src 之前或重新加载时设置)
if
(
!
this
.
fragmentVideoElement
.
crossOrigin
)
{
this
.
fragmentVideoElement
.
crossOrigin
=
'
anonymous
'
;
}
// 如果视频已经加载完成,获取时长并生成缩略图
if
(
this
.
fragmentVideoElement
.
readyState
>=
2
)
{
this
.
fragmentVideoDuration
=
this
.
fragmentVideoElement
.
duration
||
0
;
if
(
this
.
fragmentSelectedTime
>
0
)
{
if
(
this
.
fragmentSelectedTime
>
0
)
{
this
.
fragmentVideoElement
.
currentTime
=
this
.
fragmentSelectedTime
;
this
.
fragmentVideoElement
.
currentTime
=
this
.
fragmentSelectedTime
;
}
}
// 生成视频帧缩略图
// 如果缓存不存在,生成缩略图
if
(
this
.
fragmentVideoDuration
>
0
&&
!
cachedData
)
{
setTimeout
(()
=>
{
this
.
generateVideoThumbnails
();
this
.
generateVideoThumbnails
();
},
100
);
}
}
else
{
// 视频未加载,等待 loadedmetadata 事件
if
(
this
.
fragmentSelectedTime
>
0
)
{
this
.
fragmentVideoElement
.
currentTime
=
this
.
fragmentSelectedTime
;
}
// 如果缓存不存在,等待加载完成后生成
if
(
!
cachedData
)
{
// 将在 onFragmentVideoLoadedMetadata 中生成
}
}
}
}
},
100
);
},
100
);
}
}
...
@@ -195,15 +235,160 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -195,15 +235,160 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
this
.
fragmentSelectedTime
=
0
;
this
.
fragmentSelectedTime
=
0
;
this
.
fragmentVideoElement
=
null
;
this
.
fragmentVideoElement
=
null
;
this
.
isDraggingTrack
=
false
;
this
.
isDraggingTrack
=
false
;
this
.
videoThumbnails
=
[];
this
.
videoThumbnails
=
[];
// 只清除当前显示的缩略图,保留缓存
this
.
isGeneratingThumbnails
=
false
;
this
.
isGeneratingThumbnails
=
false
;
}
}
// 清除指定视频的缩略图缓存
clearThumbnailCache
(
videoUrl
?:
string
)
{
if
(
videoUrl
)
{
this
.
thumbnailCache
.
delete
(
videoUrl
);
// 同时从 localStorage 清除
this
.
removeThumbnailCacheFromLocalStorage
(
videoUrl
);
console
.
log
(
'
已清除缓存:
'
,
videoUrl
);
}
else
{
// 清除所有缓存
this
.
thumbnailCache
.
clear
();
// 同时清除 localStorage
this
.
clearThumbnailCacheFromLocalStorage
();
console
.
log
(
'
已清除所有缩略图缓存
'
);
}
}
// 保存缩略图缓存到 localStorage
saveThumbnailCacheToLocalStorage
(
videoUrl
:
string
,
cacheData
:
{
thumbnails
:
string
[],
duration
:
number
,
count
:
number
})
{
try
{
// 获取现有的缓存
const
existingCache
=
this
.
getThumbnailCacheFromLocalStorage
();
// 更新或添加新的缓存项
existingCache
[
videoUrl
]
=
cacheData
;
// 限制存储的视频数量,避免超出 localStorage 大小限制(通常 5-10MB)
const
maxCachedVideos
=
10
;
// 最多缓存10个视频的缩略图
const
cacheEntries
=
Object
.
keys
(
existingCache
);
if
(
cacheEntries
.
length
>
maxCachedVideos
)
{
// 删除最旧的缓存(简单策略:删除第一个)
const
oldestKey
=
cacheEntries
[
0
];
delete
existingCache
[
oldestKey
];
console
.
log
(
'
缓存已满,删除最旧的缓存:
'
,
oldestKey
);
}
// 尝试保存到 localStorage
const
cacheJson
=
JSON
.
stringify
(
existingCache
);
const
cacheSize
=
new
Blob
([
cacheJson
]).
size
;
const
maxSize
=
4
*
1024
*
1024
;
// 4MB 限制,留一些余量
if
(
cacheSize
>
maxSize
)
{
console
.
warn
(
'
缩略图缓存大小超出限制,跳过保存到 localStorage
'
);
return
;
}
localStorage
.
setItem
(
this
.
thumbnailCacheKey
,
cacheJson
);
console
.
log
(
'
缩略图缓存已保存到 localStorage,大小:
'
,
(
cacheSize
/
1024
).
toFixed
(
2
),
'
KB
'
);
}
catch
(
error
)
{
console
.
error
(
'
保存缩略图缓存到 localStorage 失败:
'
,
error
);
// 如果存储失败(如超出大小限制),尝试清理一些旧缓存
if
(
error
.
name
===
'
QuotaExceededError
'
)
{
console
.
warn
(
'
localStorage 空间不足,尝试清理旧缓存
'
);
this
.
clearOldThumbnailCache
();
}
}
}
// 从 localStorage 加载缩略图缓存
loadThumbnailCacheFromLocalStorage
()
{
try
{
const
cacheJson
=
localStorage
.
getItem
(
this
.
thumbnailCacheKey
);
if
(
!
cacheJson
)
{
return
;
}
const
cacheData
=
JSON
.
parse
(
cacheJson
);
// 将对象转换为 Map
for
(
const
[
videoUrl
,
data
]
of
Object
.
entries
(
cacheData
))
{
this
.
thumbnailCache
.
set
(
videoUrl
,
data
as
{
thumbnails
:
string
[],
duration
:
number
,
count
:
number
});
}
console
.
log
(
'
从 localStorage 加载缩略图缓存,视频数量:
'
,
this
.
thumbnailCache
.
size
);
}
catch
(
error
)
{
console
.
error
(
'
从 localStorage 加载缩略图缓存失败:
'
,
error
);
// 如果解析失败,清除损坏的缓存
localStorage
.
removeItem
(
this
.
thumbnailCacheKey
);
}
}
// 从 localStorage 获取所有缓存(返回对象格式)
getThumbnailCacheFromLocalStorage
():
{
[
key
:
string
]:
{
thumbnails
:
string
[],
duration
:
number
,
count
:
number
}
}
{
try
{
const
cacheJson
=
localStorage
.
getItem
(
this
.
thumbnailCacheKey
);
if
(
!
cacheJson
)
{
return
{};
}
return
JSON
.
parse
(
cacheJson
);
}
catch
(
error
)
{
console
.
error
(
'
读取 localStorage 缓存失败:
'
,
error
);
return
{};
}
}
// 从 localStorage 删除指定视频的缓存
removeThumbnailCacheFromLocalStorage
(
videoUrl
:
string
)
{
try
{
const
existingCache
=
this
.
getThumbnailCacheFromLocalStorage
();
delete
existingCache
[
videoUrl
];
localStorage
.
setItem
(
this
.
thumbnailCacheKey
,
JSON
.
stringify
(
existingCache
));
}
catch
(
error
)
{
console
.
error
(
'
从 localStorage 删除缓存失败:
'
,
error
);
}
}
// 清除 localStorage 中的所有缩略图缓存
clearThumbnailCacheFromLocalStorage
()
{
try
{
localStorage
.
removeItem
(
this
.
thumbnailCacheKey
);
}
catch
(
error
)
{
console
.
error
(
'
清除 localStorage 缓存失败:
'
,
error
);
}
}
// 清理旧的缩略图缓存(保留最新的几个)
clearOldThumbnailCache
()
{
try
{
const
existingCache
=
this
.
getThumbnailCacheFromLocalStorage
();
const
cacheEntries
=
Object
.
keys
(
existingCache
);
// 只保留最新的3个缓存
if
(
cacheEntries
.
length
>
3
)
{
const
entriesToKeep
=
cacheEntries
.
slice
(
-
3
);
const
newCache
:
{
[
key
:
string
]:
any
}
=
{};
entriesToKeep
.
forEach
(
key
=>
{
newCache
[
key
]
=
existingCache
[
key
];
});
localStorage
.
setItem
(
this
.
thumbnailCacheKey
,
JSON
.
stringify
(
newCache
));
console
.
log
(
'
已清理旧缓存,保留最新的
'
,
entriesToKeep
.
length
,
'
个
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
清理旧缓存失败:
'
,
error
);
}
}
// 视频片段弹窗 - 确认
// 视频片段弹窗 - 确认
fragmentModalOk
()
{
fragmentModalOk
()
{
if
(
this
.
currentFragmentQues
)
{
if
(
this
.
currentFragmentQues
)
{
// 保存选中的时间
this
.
currentFragmentQues
.
time
=
this
.
fragmentSelectedTime
;
this
.
currentFragmentQues
.
time
=
this
.
fragmentSelectedTime
;
this
.
save
();
this
.
save
();
// 触发变更检测,确保分钟和秒输入框更新
this
.
changeDetectorRef
.
detectChanges
();
// 在下一个变更检测周期再次触发,确保视图完全更新
setTimeout
(()
=>
{
this
.
changeDetectorRef
.
detectChanges
();
},
0
);
}
}
this
.
fragmentModalCancel
();
this
.
fragmentModalCancel
();
}
}
...
@@ -211,11 +396,44 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -211,11 +396,44 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
// 视频加载元数据
// 视频加载元数据
onFragmentVideoLoadedMetadata
(
event
)
{
onFragmentVideoLoadedMetadata
(
event
)
{
const
video
=
event
.
target
;
const
video
=
event
.
target
;
// 确保设置 crossOrigin 属性
if
(
!
video
.
crossOrigin
)
{
video
.
crossOrigin
=
'
anonymous
'
;
}
this
.
fragmentVideoDuration
=
video
.
duration
||
0
;
this
.
fragmentVideoDuration
=
video
.
duration
||
0
;
this
.
fragmentVideoElement
=
video
;
this
.
fragmentVideoElement
=
video
;
if
(
this
.
fragmentSelectedTime
>
0
)
{
if
(
this
.
fragmentSelectedTime
>
0
)
{
video
.
currentTime
=
this
.
fragmentSelectedTime
;
video
.
currentTime
=
this
.
fragmentSelectedTime
;
}
}
// 检查缓存
const
videoUrl
=
this
.
item
.
video_url
?
this
.
item
.
video_url
.
replace
(
'
.mp4
'
,
'
_h.mp4
'
)
:
''
;
const
cachedData
=
this
.
thumbnailCache
.
get
(
videoUrl
);
// 视频元数据加载完成后,如果缓存不存在则生成缩略图
if
(
this
.
fragmentVideoDuration
>
0
&&
this
.
isFragmentModalVisible
&&
!
cachedData
)
{
// 延迟一下确保视频完全准备好
setTimeout
(()
=>
{
this
.
generateVideoThumbnails
();
},
200
);
}
else
if
(
cachedData
&&
this
.
videoThumbnails
.
length
===
0
)
{
// 如果缓存存在但缩略图数组为空,从缓存恢复
this
.
videoThumbnails
=
cachedData
.
thumbnails
;
this
.
changeDetectorRef
.
detectChanges
();
}
}
// 视频加载错误处理
onFragmentVideoError
(
event
)
{
console
.
error
(
'
视频加载错误:
'
,
event
);
const
video
=
event
.
target
;
if
(
video
.
error
)
{
console
.
error
(
'
视频错误代码:
'
,
video
.
error
.
code
,
'
错误信息:
'
,
video
.
error
.
message
);
// 如果是因为跨域问题,尝试重新设置
if
(
video
.
error
.
code
===
MediaError
.
MEDIA_ERR_SRC_NOT_SUPPORTED
)
{
this
.
message
.
create
(
'
warning
'
,
'
视频加载失败,可能是跨域问题。请确保视频服务器支持 CORS。
'
);
}
}
}
}
// 视频时间更新
// 视频时间更新
...
@@ -223,7 +441,13 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -223,7 +441,13 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
if
(
!
this
.
isDraggingTrack
)
{
if
(
!
this
.
isDraggingTrack
)
{
const
video
=
event
.
target
;
const
video
=
event
.
target
;
this
.
fragmentVideoCurrentTime
=
video
.
currentTime
;
this
.
fragmentVideoCurrentTime
=
video
.
currentTime
;
const
previousSelectedIndex
=
Math
.
floor
(
this
.
fragmentSelectedTime
);
this
.
fragmentSelectedTime
=
video
.
currentTime
;
this
.
fragmentSelectedTime
=
video
.
currentTime
;
// 只有当时间跨越到新的缩略图索引时才滚动(避免频繁滚动)
const
currentSelectedIndex
=
Math
.
floor
(
this
.
fragmentSelectedTime
);
if
(
currentSelectedIndex
!==
previousSelectedIndex
)
{
this
.
scrollToActiveThumbnail
();
}
}
}
}
}
...
@@ -238,6 +462,8 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -238,6 +462,8 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
if
(
this
.
fragmentVideoElement
)
{
if
(
this
.
fragmentVideoElement
)
{
this
.
fragmentVideoElement
.
currentTime
=
value
;
this
.
fragmentVideoElement
.
currentTime
=
value
;
}
}
// 滚动到对应的缩略图
this
.
scrollToActiveThumbnail
();
}
}
// 轨道拖动结束
// 轨道拖动结束
...
@@ -260,7 +486,28 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -260,7 +486,28 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
// 生成视频帧缩略图
// 生成视频帧缩略图
async
generateVideoThumbnails
()
{
async
generateVideoThumbnails
()
{
if
(
!
this
.
fragmentVideoElement
||
!
this
.
fragmentVideoDuration
)
{
if
(
!
this
.
fragmentVideoElement
)
{
return
;
}
// 如果视频时长还没有获取到,尝试从视频元素获取
if
(
!
this
.
fragmentVideoDuration
&&
this
.
fragmentVideoElement
.
duration
)
{
this
.
fragmentVideoDuration
=
this
.
fragmentVideoElement
.
duration
;
}
if
(
!
this
.
fragmentVideoDuration
||
this
.
fragmentVideoDuration
<=
0
)
{
console
.
warn
(
'
视频时长未获取到,无法生成缩略图
'
);
return
;
}
// 检查缓存
const
videoUrl
=
this
.
item
.
video_url
?
this
.
item
.
video_url
.
replace
(
'
.mp4
'
,
'
_h.mp4
'
)
:
''
;
const
cachedData
=
this
.
thumbnailCache
.
get
(
videoUrl
);
if
(
cachedData
&&
cachedData
.
thumbnails
.
length
>
0
)
{
console
.
log
(
'
使用缓存的缩略图,跳过生成
'
);
this
.
videoThumbnails
=
cachedData
.
thumbnails
;
this
.
thumbnailCount
=
cachedData
.
count
;
this
.
changeDetectorRef
.
detectChanges
();
return
;
return
;
}
}
...
@@ -268,11 +515,40 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -268,11 +515,40 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
this
.
videoThumbnails
=
[];
this
.
videoThumbnails
=
[];
const
video
=
this
.
fragmentVideoElement
;
const
video
=
this
.
fragmentVideoElement
;
// 确保设置 crossOrigin 属性
if
(
!
video
.
crossOrigin
)
{
video
.
crossOrigin
=
'
anonymous
'
;
// 如果视频已经加载,需要重新加载
if
(
video
.
readyState
>=
2
)
{
const
currentSrc
=
video
.
src
;
video
.
src
=
''
;
video
.
crossOrigin
=
'
anonymous
'
;
video
.
src
=
currentSrc
;
// 等待视频重新加载
await
new
Promise
((
resolve
,
reject
)
=>
{
const
onLoadedMetadata
=
()
=>
{
video
.
removeEventListener
(
'
loadedmetadata
'
,
onLoadedMetadata
);
video
.
removeEventListener
(
'
error
'
,
onError
);
resolve
(
null
);
};
const
onError
=
()
=>
{
video
.
removeEventListener
(
'
loadedmetadata
'
,
onLoadedMetadata
);
video
.
removeEventListener
(
'
error
'
,
onError
);
reject
(
new
Error
(
'
视频重新加载失败
'
));
};
video
.
addEventListener
(
'
loadedmetadata
'
,
onLoadedMetadata
);
video
.
addEventListener
(
'
error
'
,
onError
);
});
}
}
const
canvas
=
document
.
createElement
(
'
canvas
'
);
const
canvas
=
document
.
createElement
(
'
canvas
'
);
const
ctx
=
canvas
.
getContext
(
'
2d
'
);
const
ctx
=
canvas
.
getContext
(
'
2d
'
);
canvas
.
width
=
160
;
// 缩略图宽度
canvas
.
width
=
160
;
// 缩略图宽度
canvas
.
height
=
90
;
// 缩略图高度 (16:9 比例)
canvas
.
height
=
90
;
// 缩略图高度 (16:9 比例)
this
.
thumbnailCount
=
Math
.
round
(
this
.
fragmentVideoDuration
);
const
interval
=
this
.
fragmentVideoDuration
/
this
.
thumbnailCount
;
const
interval
=
this
.
fragmentVideoDuration
/
this
.
thumbnailCount
;
const
originalTime
=
video
.
currentTime
;
const
originalTime
=
video
.
currentTime
;
const
originalPaused
=
video
.
paused
;
const
originalPaused
=
video
.
paused
;
...
@@ -283,6 +559,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -283,6 +559,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
}
}
// 逐个提取帧
// 逐个提取帧
try
{
for
(
let
i
=
0
;
i
<
this
.
thumbnailCount
;
i
++
)
{
for
(
let
i
=
0
;
i
<
this
.
thumbnailCount
;
i
++
)
{
const
time
=
i
*
interval
;
const
time
=
i
*
interval
;
await
this
.
seekToTime
(
video
,
time
);
await
this
.
seekToTime
(
video
,
time
);
...
@@ -293,13 +570,23 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -293,13 +570,23 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
// 绘制当前帧到 canvas
// 绘制当前帧到 canvas
ctx
.
drawImage
(
video
,
0
,
0
,
canvas
.
width
,
canvas
.
height
);
ctx
.
drawImage
(
video
,
0
,
0
,
canvas
.
width
,
canvas
.
height
);
// 转换为 base64 图片
// 转换为 base64 图片,添加错误处理
try
{
const
thumbnail
=
canvas
.
toDataURL
(
'
image/jpeg
'
,
0.7
);
const
thumbnail
=
canvas
.
toDataURL
(
'
image/jpeg
'
,
0.7
);
this
.
videoThumbnails
.
push
(
thumbnail
);
this
.
videoThumbnails
.
push
(
thumbnail
);
}
catch
(
error
)
{
console
.
error
(
'
生成缩略图失败 (可能是跨域问题):
'
,
error
);
// 如果跨域问题无法解决,使用占位符
this
.
videoThumbnails
.
push
(
'
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYwIiBoZWlnaHQ9IjkwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxNjAiIGhlaWdodD0iOTAiIGZpbGw9IiNlZWUiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPuaXoOazleWKoOi9veWbvueJhzwvdGV4dD48L3N2Zz4=
'
);
}
// 更新视图
// 更新视图
this
.
changeDetectorRef
.
detectChanges
();
this
.
changeDetectorRef
.
detectChanges
();
}
}
}
catch
(
error
)
{
console
.
error
(
'
生成缩略图过程中出错:
'
,
error
);
this
.
message
.
create
(
'
error
'
,
'
生成缩略图失败,可能是跨域问题。请确保视频服务器支持 CORS。
'
);
}
// 恢复视频状态
// 恢复视频状态
if
(
!
originalPaused
)
{
if
(
!
originalPaused
)
{
...
@@ -308,6 +595,20 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -308,6 +595,20 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
await
this
.
seekToTime
(
video
,
originalTime
);
await
this
.
seekToTime
(
video
,
originalTime
);
}
}
// 将生成的缩略图存入缓存
if
(
this
.
videoThumbnails
.
length
>
0
)
{
const
videoUrl
=
this
.
item
.
video_url
?
this
.
item
.
video_url
.
replace
(
'
.mp4
'
,
'
_h.mp4
'
)
:
''
;
const
cacheData
=
{
thumbnails
:
[...
this
.
videoThumbnails
],
// 创建副本避免引用问题
duration
:
this
.
fragmentVideoDuration
,
count
:
this
.
thumbnailCount
};
this
.
thumbnailCache
.
set
(
videoUrl
,
cacheData
);
// 同时保存到 localStorage
this
.
saveThumbnailCacheToLocalStorage
(
videoUrl
,
cacheData
);
console
.
log
(
'
缩略图已缓存,视频URL:
'
,
videoUrl
,
'
缩略图数量:
'
,
this
.
videoThumbnails
.
length
);
}
this
.
isGeneratingThumbnails
=
false
;
this
.
isGeneratingThumbnails
=
false
;
this
.
changeDetectorRef
.
detectChanges
();
this
.
changeDetectorRef
.
detectChanges
();
}
}
...
@@ -330,29 +631,73 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -330,29 +631,73 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
if
(
this
.
fragmentVideoElement
)
{
if
(
this
.
fragmentVideoElement
)
{
this
.
fragmentVideoElement
.
currentTime
=
time
;
this
.
fragmentVideoElement
.
currentTime
=
time
;
}
}
// 点击时也滚动到该缩略图(虽然已经在可视区域,但可以确保居中)
setTimeout
(()
=>
{
this
.
scrollToActiveThumbnail
();
},
100
);
}
// 滚动到激活的缩略图位置(居中显示)
scrollToActiveThumbnail
()
{
if
(
!
this
.
thumbnailContainer
||
!
this
.
fragmentVideoDuration
||
this
.
thumbnailCount
<=
0
)
{
return
;
}
const
container
=
this
.
thumbnailContainer
.
nativeElement
;
const
selectedIndex
=
Math
.
floor
(
this
.
fragmentSelectedTime
);
// 确保索引在有效范围内
if
(
selectedIndex
<
0
||
selectedIndex
>=
this
.
videoThumbnails
.
length
)
{
return
;
}
const
thumbnailElement
=
document
.
getElementById
(
`thumbnail-
${
selectedIndex
}
`
);
if
(
!
thumbnailElement
)
{
return
;
}
// 计算滚动位置,使缩略图居中
const
containerRect
=
container
.
getBoundingClientRect
();
const
thumbnailRect
=
thumbnailElement
.
getBoundingClientRect
();
const
containerScrollLeft
=
container
.
scrollLeft
;
const
containerWidth
=
containerRect
.
width
;
const
thumbnailWidth
=
thumbnailRect
.
width
;
const
thumbnailLeft
=
thumbnailElement
.
offsetLeft
;
// 计算目标滚动位置:缩略图中心对齐容器中心
const
targetScrollLeft
=
thumbnailLeft
-
(
containerWidth
/
2
)
+
(
thumbnailWidth
/
2
);
// 平滑滚动
container
.
scrollTo
({
left
:
targetScrollLeft
,
behavior
:
'
smooth
'
});
}
}
// 获取缩略图对应的时间
// 获取缩略图对应的时间
getThumbnailTime
(
index
:
number
):
number
{
getThumbnailTime
(
index
:
number
):
number
{
if
(
!
this
.
fragmentVideoDuration
)
{
if
(
!
this
.
fragmentVideoDuration
||
this
.
thumbnailCount
<=
0
)
{
return
0
;
return
0
;
}
}
if
(
index
>=
this
.
thumbnailCount
)
{
if
(
index
>=
this
.
thumbnailCount
)
{
return
this
.
fragmentVideoDuration
;
return
this
.
fragmentVideoDuration
;
}
}
return
(
index
*
this
.
fragmentVideoDuration
)
/
this
.
thumbnailCount
;
// 使用与生成缩略图时相同的计算方式:每秒一张图
// 索引0对应0秒,索引1对应1秒,索引2对应2秒...
return
index
;
}
}
// 判断缩略图是否处于激活状态(当前时间在该帧范围内)
// 判断缩略图是否处于激活状态(当前时间在该帧范围内)
isThumbnailActive
(
index
:
number
):
boolean
{
isThumbnailActive
(
index
:
number
):
boolean
{
if
(
!
this
.
fragmentVideoDuration
)
{
if
(
!
this
.
fragmentVideoDuration
||
this
.
thumbnailCount
<=
0
)
{
return
false
;
return
false
;
}
}
const
currentTime
=
this
.
getThumbnailTime
(
index
);
const
thumbnailTime
=
this
.
getThumbnailTime
(
index
);
const
nextTime
=
index
<
this
.
thumbnailCount
-
1
// 由于每秒一张图,每个缩略图代表1秒的时间范围
?
this
.
getThumbnailTime
(
index
+
1
)
// 索引i代表时间[i, i+1)秒,最后一个索引代表[lastIndex, duration]秒
:
this
.
fragmentVideoDuration
;
// 使用向下取整来判断当前时间属于哪个缩略图
return
this
.
fragmentSelectedTime
>=
currentTime
&&
this
.
fragmentSelectedTime
<
nextTime
;
const
selectedIndex
=
Math
.
floor
(
this
.
fragmentSelectedTime
);
return
selectedIndex
===
index
;
}
}
initItem
()
{
initItem
()
{
...
@@ -759,13 +1104,11 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
...
@@ -759,13 +1104,11 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
}
}
/**
/**
* 删除
视频
* 删除
卡片
*/
*/
onRemoveVideo
()
{
onRemoveVideo
()
{
// 清除视频URL和封面,使用户可以重新上传视频
this
.
item
.
video_url
=
""
;
this
.
item
.
cover
=
""
;
this
.
save
();
}
}
/** 添加卡片 */
/** 添加卡片 */
...
...
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