在线剪辑demo

This commit is contained in:
LisianthusLeaf
2024-07-01 17:59:18 +08:00
parent 1917051558
commit c4c4b73d52
12 changed files with 440 additions and 9 deletions
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ASMPluginConfiguration">
<asm skipDebug="false" skipFrames="false" skipCode="false" expandFrames="false" />
+2
View File
@@ -0,0 +1,2 @@
<svg xml:space="preserve" viewBox="0 0 100 100" y="0" x="0" xmlns="http://www.w3.org/2000/svg" version="1.1" style="margin: initial; display: block; shape-rendering: auto;" preserveAspectRatio="xMidYMid" width="200" height="200"><g class="ldl-scale" style="transform-origin: 50% 50%; transform: rotate(0deg) scale(1.2, 1.2);"><g class="ldl-ani"><g class="ldl-layer"><g class="ldl-ani" style="transform: matrix3d(0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 1); transform-box: view-box; opacity: 1; animation: 1s linear -0.75s infinite normal forwards running animate; transform-origin: 50px 50px;"><circle stroke="#333" stroke-width="8" fill="#fff" stroke-miterlimit="10" r="37" cy="50" cx="50" style="stroke-width: 8; fill: rgb(255, 255, 255); stroke: rgb(51, 51, 51);"></circle></g></g><g class="ldl-layer"><g class="ldl-ani" style="transform: matrix3d(0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 1); transform-box: view-box; opacity: 1; animation: 1s linear -1s infinite normal forwards running animate; transform-origin: 50px 50px;"><path fill="#333" d="M73.265 51.254l-35.594 20.55A1.447 1.447 0 0 1 35.5 70.55v-41.1a1.448 1.448 0 0 1 2.171-1.254l35.594 20.55c.965.558.965 1.95 0 2.508z" style="stroke-width: 1; fill: rgb(51, 51, 51);"></path></g></g>
</g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+2
View File
@@ -0,0 +1,2 @@
<svg xml:space="preserve" viewBox="0 0 100 100" y="0" x="0" xmlns="http://www.w3.org/2000/svg" version="1.1" style="margin: initial; display: block; shape-rendering: auto;" preserveAspectRatio="xMidYMid" width="200" height="200"><g class="ldl-scale" style="transform-origin: 50% 50%; transform: rotate(0deg) scale(1.2, 1.2);"><g class="ldl-ani"><g class="ldl-layer"><g class="ldl-ani" style="transform: matrix3d(0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 1); transform-box: view-box; opacity: 1; animation: 1s linear -0.666667s infinite normal forwards running animate; transform-origin: 50px 50px;"><circle stroke="#333" stroke-width="8" fill="#fff" stroke-miterlimit="10" r="37" cy="50" cx="50" style="stroke-width: 8; fill: rgb(255, 255, 255); stroke: rgb(51, 51, 51);"></circle></g></g><g class="ldl-layer"><g class="ldl-ani" style="transform: matrix3d(0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 1); transform-box: view-box; opacity: 1; animation: 1s linear -0.833333s infinite normal forwards running animate; transform-origin: 50px 50px;"><path fill="#333" d="M45.39 70H34.841c-.666 0-1.205-.54-1.205-1.205v-37.59c0-.666.54-1.205 1.205-1.205H45.39c.666 0 1.205.54 1.205 1.205v37.59c0 .665-.54 1.205-1.205 1.205z" style="stroke-width: 1; fill: rgb(51, 51, 51);"></path></g></g><g class="ldl-layer"><g class="ldl-ani" style="transform: matrix3d(0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 0.91, 0, 0, 0, 0, 1); transform-box: view-box; opacity: 1; animation: 1s linear -1s infinite normal forwards running animate; transform-origin: 50px 50px;"><path fill="#333" d="M65.159 70H54.61c-.666 0-1.205-.54-1.205-1.205v-37.59c0-.666.54-1.205 1.205-1.205h10.549c.666 0 1.205.54 1.205 1.205v37.59c0 .665-.54 1.205-1.205 1.205z" style="stroke-width: 1; fill: rgb(51, 51, 51);"></path></g></g>
</g></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+2 -2
View File
@@ -22,12 +22,12 @@ export default [
},
{
path: "/ai/Online_Clip",
component: () => import("@/views/chatgpt/OnlineClip.vue"),
component: () => import("@/views/chatgpt/OnlineClip.vue"),
meta: {
requiresAuth: true,
layout: "ui",
category: "AI",
title: "ChatBotV2",
title: "OnlineClip",
},
},
];
+18
View File
@@ -60,3 +60,21 @@ export function getTimeAgo(input: string): string {
return 'now';
}
}
/**
* 将number类型的时间转换成“00:00:00:00”格式
*/
function padZero(num: number, size = 2): string {
let s = num.toString();
while (s.length < size) s = '0' + s;
return s;
}
export function formatDuration(durationInSeconds: number): string {
const totalMilliseconds = Math.round(durationInSeconds * 1000); // 四舍五入到最近的毫秒
const seconds = Math.floor((totalMilliseconds / 1000) % 60);
const minutes = Math.floor((totalMilliseconds / (1000 * 60)) % 60);
const hours = Math.floor((totalMilliseconds / (1000 * 60 * 60)) % 24);
const milliseconds = ('00' + (totalMilliseconds % 1000)).slice(-2); // 保留毫秒的前两位
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}:${milliseconds}`;
}
+2 -2
View File
@@ -1,7 +1,7 @@
<!--
* @Component:
* @Component:
* @Maintainer: J.K. Yang
* @Description:
* @Description:
-->
<script setup lang="ts">
import { useSnackbarStore } from "@/stores/snackbarStore";
+21 -4
View File
@@ -1,13 +1,30 @@
<!--
* @Component: 在线剪辑页面
* @Component: 在线剪辑
* @Maintainer: Lisianthus
* @Description:
-->
<script setup lang="ts">
import { useSnackbarStore } from "@/stores/snackbarStore";
const snackbarStore = useSnackbarStore();
import Player from "./component/Player.vue";
import Timeline from "./component/Timeline.vue";
import VideoChoose from "./component/VideoChoose.vue";
import {useEditingStore} from "@/views/chatgpt/editingStore";
const editingStore = useEditingStore()
editingStore.initEditing()
</script>
<template>
<div></div>
<v-card class="editing">
<!--视频选择组件-->
<VideoChoose></VideoChoose>
<!--播放组件-->
<Player></Player>
<!--时间轴选择组件-->
<Timeline></Timeline>
</v-card>
</template>
<style scoped>
.editing{
height:450px;
width:700px;
}
</style>
@@ -0,0 +1,54 @@
<!--
* @Component: 播放组件
* @Maintainer: Lisianthus
* @Description:控制视频播放状态
-->
<script setup lang="ts">
import {useEditingStore} from "@/views/chatgpt/editingStore";
import PlayIcon from '@/assets/icons/play.svg'
import PauseIcon from '@/assets/icons/pause.svg'
const editingStore = useEditingStore();
</script>
<template>
<div class="player flex">
<div class="time-box">
<label>{{ editingStore.formattedPlayStart }} </label>
<label style="color:#c0c0c0"> / {{ editingStore.formattedVideoLong }}</label>
</div>
<button @click="editingStore.setPlayStatus" class="player-btn">
<img :src="editingStore.play_status ? PlayIcon : PauseIcon" alt="Play Icon"/>
</button>
<div class="time-box">
<label>{{ editingStore.formattedStartTime }}</label>
<label> / {{ editingStore.formattedEndTime }}</label>
</div>
</div>
</template>
<style scoped lang="scss">
.flex{
display: flex;
align-items: center;
justify-content: space-evenly;
}
.player{
position: relative;
left:10px;
margin-top:15px;
width:680px;
height:45px;
box-shadow: #D7D7D7 0 0 5px;
}
.player-btn{
width:35px;
height:35px;
border-radius: 50px;
box-shadow: 0 0 5px #D7D7D7;
overflow: hidden;
}
.time-box{
width:200px;
}
</style>
@@ -0,0 +1,156 @@
<!--
* @Component: 时间轴组件
* @Maintainer: Lisianthus
* @Description: 按时间轴剪辑视频
-->
<script setup lang="ts">
import { useEditingStore } from "@/views/chatgpt/editingStore";
const editingStore = useEditingStore()
let width = ref(400) // 视频分帧默认初始显示宽度
// 比例因子:1秒 = {scaleFactor}px
const scaleFactor = width.value / editingStore.getLong;
// 剪辑开始时间的像素位置
let start_time = ref(scaleFactor * editingStore.start_time)
// 剪辑结束时间的像素位置
let end_time = ref(scaleFactor * editingStore.end_time)
// 绘制时间刻度轴
function drawTimeline() {
const canvas = document.getElementById('timelineCanvas') as HTMLCanvasElement
if(!canvas)return;
const ctx = canvas.getContext('2d');
if(!ctx)return
// 假设每个大格的高度是 20px,每个小格的高度是 5px
const bigGridHeight = 20;
const smallGridHeight = 5;
// 清除画布
ctx.clearRect(0, 0, canvas.width, bigGridHeight);
// 绘制大格
for (let i = 0; i * scaleFactor <= canvas.width; i++) {
const x = i * scaleFactor * 60; // 默认一分钟一大格
ctx.fillRect(x, 0, 1, bigGridHeight);
ctx.fillText(i +":00",x+5,17)
}
// 绘制小格(如果需要的话)
for (let i = 0; i <= canvas.width; i++) {
const x = i * scaleFactor * 6; // 默认6秒一小格
ctx.fillRect(x, 0, 1, smallGridHeight);
}
}
// 监听 editingStore.video_long 的变化
watch(() => editingStore.getLong, (newVal) => {
drawTimeline();
});
onMounted(() => {
drawTimeline();
})
// 拖动句柄选择剪辑区域
const dragging = ref<'start' | 'end' | null>(null);
let startX = 0;
let endX = 0;
const container = ref<HTMLDivElement | null>(null);
// 拖动头句柄
const startDragStart = (e: MouseEvent) => {
if (!container.value) return;
dragging.value = 'start';
// e.clientX是鼠标相对于浏览器窗口的X坐标
// container.value.offsetLeft是相对于其最近的已定位父元素的左边界的距离
startX = e.clientX - container.value.getBoundingClientRect().left
console.log(e.clientX,container.value.getBoundingClientRect().left)
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
};
// 拖动尾句柄
const startDragEnd = (e: MouseEvent) => {
if (!container.value) return;
dragging.value = 'end';
endX = e.clientX - container.value.getBoundingClientRect().left;
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
};
// 拖动事件
const drag = (e: MouseEvent) => {
if (!dragging.value || !container.value) return;
if(e.clientX < container.value.getBoundingClientRect().left ||
e.clientX - container.value.getBoundingClientRect().left > width.value) return;
if (dragging.value === 'start') {
start_time.value = e.clientX - container.value.getBoundingClientRect().left;
} else {
end_time.value = e.clientX - container.value.getBoundingClientRect().left;
}
start_time.value = Math.min(start_time.value,end_time.value)
editingStore.setStartTime(start_time.value / scaleFactor)
end_time.value = Math.max(start_time.value,end_time.value)
editingStore.setEndTime(end_time.value / scaleFactor)
};
// 停止拖动事件
const stopDrag = () => {
dragging.value = null;
// 清除拖动监听
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
};
</script>
<template>
<div class="timeline-container">
<div class="timeline-bar" >
<canvas id="timelineCanvas" width="680" height="20"></canvas>
</div>
<div ref="container" class="frame-bar" :style="{ width: `${width}px` }">
<div class="framing"></div>
<div class="cut-choose" :style="{ left: `${start_time}px`,width: `${end_time - start_time}px` }"></div>
<div @mousedown="startDragStart" class="cut-hand" :style="{left: `${start_time}px`}"></div>
<div @mousedown="startDragEnd" class="cut-hand" :style="{left: `${end_time}px`}"></div>
</div>
</div>
</template>
<style scoped lang="scss">
.timeline-container{
background-color: whitesmoke;
}
.timeline-bar{
position: relative;
left:10px;
margin-top:15px;
width:680px;
height:20px;
}
.frame-bar{
position: relative;
left:10px;
margin-top:5px;
height:60px;
background-color: #cccccc;
}
.framing{
position: absolute;
height: 100%;
width:100%;
}
.cut-choose{
position: absolute;
background-color: #3fc7f3;
height:100%;
}
.cut-hand {
display: flex;
align-items: center;
position: absolute;
top: 0;
user-select: none; /* 禁止选择文本 */
width: 10px;
height: 100%;
background-color: white;
cursor: ew-resize;
border: solid 1px #e8e8e8;
}
</style>
@@ -0,0 +1,83 @@
<!--
* @Component: 视频选择
* @Maintainer: Lisianthus
* @Description:选择要编辑的视频
-->
<script setup lang="ts">
import { ref } from 'vue';
import { useEditingStore } from "@/views/chatgpt/editingStore";
const editingStore = useEditingStore()
const videoRef = ref<HTMLVideoElement | null>(null);
// 控制视频暂停和播放
function playMedia(status: boolean) {
if (videoRef.value) {
if(status){
videoRef.value.play().catch(error => {
console.error('Error playing video:', error);
});
}else{
videoRef.value.pause();
}
}
}
// 通过监听store播放状态来控制播放
watch(() => editingStore.play_status, (newVal, oldVal) => {playMedia(newVal)}, { immediate: true })
// 上传视频文件
function onFileChange(event: Event) {
const fileInput = event.target as HTMLInputElement;
const file = fileInput.files?.[0];
if (file) {
console.log(URL.createObjectURL(file))
editingStore.setVideo(URL.createObjectURL(file))
}
}
// 获取视频时长
function handleVideoMetadata() {
if (videoRef.value) {
editingStore.setDuration(videoRef.value.duration)
}
}
// 更新播放位置
function updateTime() {
if (videoRef.value) {
editingStore.play_start = videoRef.value.currentTime
if(editingStore.play_start === editingStore.video_long){
editingStore.play_status = false
}
}
}
</script>
<template>
<div class="video-box">
<input type="file" accept="video/*" @change="onFileChange" v-show="!editingStore.video_url"/>
<video v-show="editingStore.getUrl"
ref="videoRef"
:src="editingStore.getUrl"
@loadedmetadata="handleVideoMetadata"
@timeupdate = "updateTime"
></video>
</div>
</template>
<style scoped lang="scss">
.video-box{
display: flex;
align-items: center;
justify-content: center;
position:relative;
left:125px;
margin-top:15px;
width:450px;
height:250px;
}
input{
position: absolute;
width: 100%;
height: 100%;
background-color: #F2F2F2;
}
video{
height: 250px;
}
</style>
@@ -0,0 +1,93 @@
import { defineStore } from "pinia";
import { Editing } from "./editingTypes"
import {sessionStorage} from "@/utils/storage";
import {formatDuration} from "@/utils/timeUtils"
export const useEditingStore = defineStore({
id: "editing",
state:()=>({
video_url: '',
video_long: 0, //视频时长
start_time: 0, //剪辑起始时间
end_time: 0, //剪辑结束时间
timeline_accuracy: 1,// 时间轴比例
play_status: false, // 播放状态
play_start: 0 //播放开始时间
}),
getters:{
getUrl:(state)=>{
return state.video_url
},
getLong:(state)=>{
return state.video_long
},
formattedVideoLong: (state) => {
return formatDuration(state.video_long);
},
formattedStartTime: (state) => {
return formatDuration(state.start_time);
},
formattedEndTime: (state) => {
return formatDuration(state.end_time);
},
formattedPlayStart: (state) => {
return formatDuration(state.play_start);
},
},
actions:{
initEditing(){
// 从本地获取之前保存的编辑信息
const editingMessage:Editing = sessionStorage.get('editingMessage') as Editing
if (editingMessage) {
console.log("刷新")
this.video_url = editingMessage.video_url;
this.video_long = editingMessage.video_long;
this.start_time = editingMessage.start_time;
this.end_time = editingMessage.end_time;
}
},
updateMessage(){
let message = <Editing>{}
message.video_url= this.video_url;
message.video_long = this.video_long
message.start_time = this.start_time;
message.end_time = this.end_time;
console.log("更新",message.video_long,message.start_time,message.end_time)
sessionStorage.set('editingMessage', message)
console.log("存入")
},
// 改变播放状态
setPlayStatus() {
this.play_status = !this.play_status;
},
// 改变播放开始时间
setPlayStart(start: number) {
this.play_start = start;
},
// 添加视频
setVideo(url:string) {
this.video_url = url
this.updateMessage()
},
setDuration(duration:number) {
this.video_long = duration
this.end_time = duration
this.updateMessage()
},
// 改变剪辑开始位置
setStartTime(start: number) {
this.start_time = start;
this.updateMessage()
},
// 改变剪辑结束位置
setEndTime(end: number) {
this.end_time = end;
this.updateMessage()
},
// 改变时间轴精度
setTimeline(timeline: number) {
this.timeline_accuracy = timeline;
},
},
})
@@ -0,0 +1,7 @@
export interface Editing{
video_url: string //视频地址
video_long: number //视频时长
// video_head: number //视频位置
start_time: number //剪辑起始时间
end_time: number //剪辑结束时间
}