This commit is contained in:
2025-08-07 16:56:41 +08:00
commit e406e68474
39 changed files with 1838 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import Loader from './views/Loader.vue';
import s1Icon from './assets/s1_icon.png';
import s2Icon from './assets/s2_icon.png';
import s3Icon from './assets/s3_icon.png';
import s4Icon from './assets/s4_icon.png';
import AniEle from './ani-comp/AniEle.vue';
import { computed, ref, useTemplateRef } from 'vue';
import eas from './assets/audio';
const currentStage = ref(-1)
const stages = [1, 2, 3, 4]
const stageIcons = [
s1Icon,
s2Icon,
s3Icon,
s4Icon,
]
const icon_grow = new URL('./assets/按钮高亮.zip', import.meta.url).href;
const icon_intro = new URL('./assets/按钮引导.zip', import.meta.url).href;
const home_intro = new URL('./assets/首页引导.zip', import.meta.url).href;
const egg_intro = new URL('./assets/晶圆引导.zip', import.meta.url).href;
const loading = ref(true);
const mainVideo = useTemplateRef('main-video');
function continueAnimation() {
const cR = mainVideo.value?.getCurrentRule().name
console.log('continueAnimation', cR);
switch (cR) {
case 'intro-enter':
mainVideo.value?.jumpTo('intro-out');
eas.play('water_interface')
eas.play('shimmering_light')
eas.play('whoosh')
break;
case 'egg-loop':
mainVideo.value?.jumpToSoftly('egg-out');
eas.play('water_interface')
eas.play('whoosh')
break;
}
}
const currentFrame = ref(0);
const showHomeIntro = computed(() => {
return currentFrame.value == 34;
});
const showEggIntro = computed(() => {
return currentFrame.value >= 124 && currentFrame.value < 175;
});
function onFrameProgress(frame: number) {
// console.log('onFrameProgress', frame);
currentFrame.value = frame;
if (frame === 240) {
currentStage.value = 0;
} else if (frame === 339) {
currentStage.value = 1;
} else if (frame === 498) {
currentStage.value = 2;
} else if (frame === 638) {
currentStage.value = 3;
} else if (frame === 1045) {
currentStage.value = -1; // Reset current stage when reaching the end
mainVideo.value?.jumpTo('intro-enter'); // Loop back to intro
}
if (frame == 868) {
eas.play('whoosh');
}
}
function clickBtn(index: number) {
if (currentStage.value === index) {
mainVideo.value?.jumpTo(`stage-${index + 1}`);
eas.play('water_interface')
currentStage.value = -1; // Reset current stage after clicking
}
}
const bgm = useTemplateRef('bgm-ele');
const bgmURL = new URL('./assets/audio/The AI Technology.mp3', import.meta.url).href;
window.addEventListener('pointerdown', () => {
if (bgm.value?.paused) {
bgm.value.play().catch((error) => {
console.error('Error playing background music:', error);
});
}
});
const isPortrait = ref(window.innerHeight > window.innerWidth);
window.addEventListener('resize', () => {
isPortrait.value = window.innerHeight > window.innerWidth;
});
document.addEventListener('pointerdown', (e) => {
//鼠标中键
if (e.buttons == 4) {
// 处理鼠标中键点击事件
document.body.requestFullscreen().catch((error) => {
console.error('Error entering fullscreen:', error);
});
}
});
const loadProgress = ref({ download: 0, decode: 0 });
const mainVideoURL = new URL('./assets/main.mp4', import.meta.url).href;
const mainZipURL = new URL('./assets/main.zip', import.meta.url).href;
</script>
<template>
<div class="app" :class="{ rotate: isPortrait }">
<audio :src="bgmURL" loop autoplay ref="bgm-ele"></audio>
<Transition name="fade">
<Loader v-if="loadProgress.decode < 1" :progress="loadProgress" />
</Transition>
<AniEle ref="main-video" class="main-video" :rules="[
{ name: 'intro-enter', duration: 33, frame: 35, loop: 1, pauseAfter: true },
{ name: 'intro-out', duration: 33, frame: 89, loop: 1 },
{ name: 'egg-loop', duration: 33, frame: 50, loop: 0 },
{ name: 'egg-out', duration: 33, frame: 67, loop: 1, pauseAfter: true },
{ name: 'stage-1', duration: 33, frame: 99, loop: 1, pauseAfter: true },
{ name: 'stage-2', duration: 33, frame: 159, loop: 1, pauseAfter: true },
{ name: 'stage-3', duration: 33, frame: 140, loop: 1, pauseAfter: true },
{ name: 'stage-4', duration: 33, frame: 407, loop: 1 },
]" :width="1920" :height="1080" stretch autoPlay log @click="continueAnimation"
:url="{ video: mainVideoURL, zip: mainZipURL }" type="video" @loading="loadProgress = $event"
@progress="onFrameProgress" />
<Transition name="fade">
<AniEle v-if="showHomeIntro" :url="home_intro" :width="444" :height="50" :rules="[
{ name: 'main', loop: 0, duration: 33, frame: 24 },
]" class="home-intro intro-text" />
</Transition>
<Transition name="fade">
<AniEle v-if="showEggIntro" :url="egg_intro" :width="516" :height="54" :rules="[
{ name: 'main', loop: 0, duration: 33, frame: 25 },
]" class="egg-intro intro-text" />
</Transition>
<Transition name="slide-in">
<div class="actions" v-if="currentStage >= 0">
<div class="action" v-for="(s, index) in stages" :key="index"
:style="{ cursor: currentStage == index ? 'pointer' : 'not-allowed' }" @click="clickBtn(index)">
<img src="./assets/icon_bg.png" alt="" class="bg" />
<img :src="stageIcons[index]" alt="" class="icon" />
<AniEle :url="icon_grow" :width="210" :height="242" :rules="[{
name: 'grow',
duration: 33,
frame: 24,
loop: 0,
}]" class="grow" v-if="index == currentStage" />
<AniEle :url="icon_intro" :width="425" :height="54" :rules="[
{ name: 'enter', duration: 33, frame: 25, loop: 1, },
{ name: 'loop', duration: 33, frame: 23, loop: 0, }]" v-if="index == currentStage" class="intro" />
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
.intro-text {
position: absolute;
transform: translate(-50%, -50%);
z-index: 1000;
height: 4.4vmin;
pointer-events: none;
&.home-intro {
left: 50%;
top: 63%;
}
&.egg-intro {
left: 70%;
top: 50%;
}
}
.app {
position: relative;
height: 100vh;
width: 100vw;
&.rotate {
height: 100vw;
width: 100vh;
transform: rotate(90deg) translateY(-100%);
transform-origin: 0% 0%;
}
}
img {
display: block;
}
.actions {
height: 100%;
background-color: #411341;
position: fixed;
right: 0;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
padding: 7vmin 7vmin 7vmin 8vmin;
z-index: 1000;
}
.action {
position: relative;
.bg {
height: 18vmin;
}
.grow {
position: absolute;
z-index: 1;
height: 22vmin;
width: fit-content;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.icon {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 8vmin;
}
.intro {
position: absolute;
left: -4vmin;
top: 50%;
transform: translate(-100%, -100%);
height: 5vmin;
}
}
.main-video {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<template>
<div class="ani-ele">
<canvas v-if="!loading && !error" ref="canvasRef" :class="{ stretch }" :width="canvasWidth"
:height="canvasHeight"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, watch } from 'vue'
import { loadFromResSmartly } from './utils'
import type { AnimationRule } from './types'
interface Props {
url: string | {
zip?: string
video?: string
}
rules: AnimationRule[]
width: number
height: number
autoPlay?: boolean
log?: boolean
stretch?: boolean
type?: 'zip' | 'video'
}
const props = withDefaults(defineProps<Props>(), {
autoPlay: true,
log: true,
type: 'zip',
})
// 响应式状态
const loading = ref(false)
const error = ref('')
const progress = ref(0)
const images = ref<string[]>([])
const loadedImages = ref<CanvasImageSource[]>([])
const canvasRef = ref<HTMLCanvasElement>()
const canvasWidth = ref(props.width)
const canvasHeight = ref(props.height)
const isPlaying = ref(false)
const isPaused = ref(false)
const currentRuleIndex = ref(0)
const currentFrame = ref(0)
const currentLoopCount = ref(0)
const animationId = ref<number>()
const lastFrameTime = ref(0)
const pendingJumpTo = ref<string>()
const pendingJumpResolver = ref<((success: boolean) => void) | null>(null)
// loadImageSequence 函数 (不变)
const loadImageSequence = async (resUrl: string | { zip?: string, video?: string }) => {
try {
loading.value = true
error.value = ''
progress.value = 0
images.value = []
loadedImages.value = []
console.log(`开始加载图片序列: ${resUrl}`);
loadedImages.value = await loadFromResSmartly({
url: typeof resUrl === 'string' ? { zip: resUrl } : resUrl,
rules: props.rules,
onprogress: (p) => emit('loading', p)
})
loading.value = false
if (props.log) console.log(`成功加载 ${images.value.length} 张图片`);
if (props.autoPlay) {
startAnimation()
}
} catch (err) {
console.error('加载图片序列失败:', err)
error.value = err instanceof Error ? err.message : '未知错误'
loading.value = false
}
}
const emit = defineEmits<{
(e: 'progress', frameIndex: number): void
(e: 'loading', progress: { download: number, decode: number }): void
}>();
// drawFrame 函数 (不变)
const drawFrame = (frameIndex: number) => {
if (!canvasRef.value || !loadedImages.value.length) return
const safeIndex = Math.max(0, Math.min(frameIndex, loadedImages.value.length - 1))
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const img = loadedImages.value[safeIndex]
// 如果img加载失败,它仍然是一个Image对象,但drawImage不会绘制任何内容,
// 从而使该帧保持透明,这正是我们想要的效果。
if (img) {
try {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
} catch (e) {
console.error(`绘制帧 ${safeIndex} 时出错:`, e)
}
}
emit('progress', safeIndex)
}
const jumpToRule = (ruleName: string): boolean => {
const ruleIndex = findRuleIndex(ruleName)
if (ruleIndex === -1) {
console.warn(`未找到名为 "${ruleName}" 的规则`)
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false);
pendingJumpResolver.value = null;
}
return false
}
if (props.log) console.log(`跳转到规则 "${ruleName}" (索引: ${ruleIndex})`);
if (isPlaying.value) {
stopAnimation()
}
currentRuleIndex.value = ruleIndex
currentLoopCount.value = 0
const targetRule = props.rules[ruleIndex]
let startFrame = targetRule.startFrame ?? 0
if (targetRule.startFrame === undefined) {
for (let i = 0; i < ruleIndex; i++) {
startFrame += props.rules[i].frame
}
}
const endFrame = targetRule.endFrame ?? (startFrame + targetRule.frame - 1);
currentFrame.value = targetRule.reverse ? endFrame : startFrame;
if (props.log) {
console.log(`跳转到规则 "${ruleName}", 实际开始帧: ${currentFrame.value}, 规则帧范围: ${startFrame} - ${endFrame}`);
}
drawFrame(currentFrame.value)
isPaused.value = false
pendingJumpTo.value = undefined
isPlaying.value = true
lastFrameTime.value = performance.now()
animate()
if (pendingJumpResolver.value) {
pendingJumpResolver.value(true);
pendingJumpResolver.value = null;
}
return true
}
// startAnimation 函数 (不变)
const startAnimation = () => {
if (!props.rules.length || !loadedImages.value.length || isPlaying.value) return
isPlaying.value = true
isPaused.value = false
if (currentRuleIndex.value >= props.rules.length) {
resetAnimation()
}
const currentRule = props.rules[currentRuleIndex.value]
let startFrame = currentRule.startFrame ?? 0
if (currentRule.startFrame === undefined) {
for (let i = 0; i < currentRuleIndex.value; i++) {
startFrame += props.rules[i].frame
}
}
const endFrame = currentRule.endFrame ?? (startFrame + currentRule.frame - 1);
currentFrame.value = currentRule.reverse ? endFrame : startFrame;
if (props.log) console.log(`开始播放规则 "${currentRule.name}", 实际开始帧: ${currentFrame.value}`);
lastFrameTime.value = performance.now()
animate()
}
// animate 函数 (不变)
const animate = () => {
if (!isPlaying.value || currentRuleIndex.value >= props.rules.length) {
animationId.value = undefined;
return;
}
animationId.value = requestAnimationFrame(animate);
const now = performance.now();
const currentRule = props.rules[currentRuleIndex.value];
const frameDuration = currentRule.duration ?? 33; // 默认约30fps
if (now - lastFrameTime.value >= frameDuration) {
lastFrameTime.value = now - (now - lastFrameTime.value) % frameDuration;
let startFrame = currentRule.startFrame ?? 0;
if (currentRule.startFrame === undefined) {
for (let i = 0; i < currentRuleIndex.value; i++) {
startFrame += props.rules[i].frame;
}
}
const endFrame = currentRule.endFrame ?? (startFrame + currentRule.frame - 1);
let nextFrame = currentFrame.value + (currentRule.reverse ? -1 : 1);
const isEndOfRuleSegment = currentRule.reverse ? (nextFrame < startFrame) : (nextFrame > endFrame);
if (isEndOfRuleSegment) {
currentLoopCount.value++;
if (pendingJumpTo.value) {
const targetRule = pendingJumpTo.value;
pendingJumpTo.value = undefined;
if (props.log) console.log(`软跳转触发,目标规则 "${targetRule}"`);
jumpToRule(targetRule);
return;
}
const isRuleFinished = currentRule.loop > 0 && currentLoopCount.value >= currentRule.loop;
if (isRuleFinished) {
if (currentRule.pauseAfter) {
isPaused.value = true;
isPlaying.value = false;
if (props.log) console.log(`规则 "${currentRule.name}" 完成,暂停等待跳转指令`);
drawFrame(currentRule.reverse ? startFrame : endFrame);
return;
}
currentRuleIndex.value++;
currentLoopCount.value = 0;
if (currentRuleIndex.value >= props.rules.length) {
isPlaying.value = false;
if (props.log) console.log('所有规则播放完毕');
drawFrame(currentRule.reverse ? startFrame : endFrame);
return;
}
const nextRule = props.rules[currentRuleIndex.value];
let nextRuleStartFrame = nextRule.startFrame ?? (endFrame + 1);
const nextRuleEndFrame = nextRule.endFrame ?? (nextRuleStartFrame + nextRule.frame - 1);
currentFrame.value = nextRule.reverse ? nextRuleEndFrame : nextRuleStartFrame;
if (props.log) console.log(`进入下一规则 "${nextRule.name}", 实际开始帧: ${currentFrame.value}`);
} else {
currentFrame.value = currentRule.reverse ? endFrame : startFrame;
}
} else {
currentFrame.value = nextFrame;
}
drawFrame(currentFrame.value);
}
};
const stopAnimation = () => {
isPlaying.value = false
isPaused.value = false
if (animationId.value) {
cancelAnimationFrame(animationId.value)
animationId.value = undefined
}
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false);
pendingJumpResolver.value = null;
}
pendingJumpTo.value = undefined;
}
const resetAnimation = () => {
stopAnimation()
currentRuleIndex.value = 0
currentFrame.value = 0
currentLoopCount.value = 0
if (loadedImages.value.length > 0) {
drawFrame(0)
}
}
// togglePlayback, resumeFromPause (不变)
const togglePlayback = () => {
if (isPlaying.value) {
stopAnimation()
} else {
startAnimation()
}
}
const resumeFromPause = () => {
if (isPaused.value) {
isPaused.value = false
isPlaying.value = true
lastFrameTime.value = performance.now()
animate()
}
}
/**
* 立即跳转到指定规则,会打断当前动画。
* @param {string} ruleName 要跳转到的规则名。
*/
const jumpTo = (ruleName: string) => {
return jumpToRule(ruleName)
}
/**
* “软”跳转。该跳转会等到当前规则循环结束后再执行。
* 如果动画已暂停或停止,则立即跳转。
* @param {string} ruleName 要跳转到的规则名。
* @returns {Promise<boolean>} 一个Promise,当成功跳转到目标规则时 resolve(true),否则 resolve(false)。
*/
const jumpToSoftly = (ruleName: string): Promise<boolean> => {
return new Promise((resolve) => {
const ruleIndex = findRuleIndex(ruleName);
if (ruleIndex === -1) {
console.warn(`无法设置软跳转:未找到规则 "${ruleName}"。`);
resolve(false);
return;
}
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false);
}
pendingJumpResolver.value = resolve;
if (isPaused.value || !isPlaying.value) {
if (props.log) console.log(`动画已暂停或停止。立即执行跳转到 "${ruleName}"。`);
jumpToRule(ruleName);
} else if (isPlaying.value) {
if (props.log) console.log(`已计划软跳转到 "${ruleName}"。将在当前循环结束后触发。`);
pendingJumpTo.value = ruleName;
}
});
}
// watch 和生命周期钩子 (不变)
watch(() => props.url, (newUrl) => {
if (newUrl) {
resetAnimation()
images.value.forEach(url => URL.revokeObjectURL(url));
images.value = [];
loadedImages.value = [];
loadImageSequence(newUrl)
}
}, { immediate: true })
watch(() => props.rules, () => {
if (loadedImages.value.length > 0) {
resetAnimation()
if (props.autoPlay) {
startAnimation()
}
}
}, { deep: true })
onBeforeUnmount(() => {
resetAnimation();
images.value.forEach(url => URL.revokeObjectURL(url));
});
// findRuleIndex (不变)
const findRuleIndex = (ruleName: string): number => {
return props.rules.findIndex(rule => rule.name === ruleName)
}
// defineExpose (不变)
defineExpose({
images,
loadedImages,
loading,
error,
isPlaying,
isPaused,
currentFrame,
currentRuleIndex,
currentLoopCount,
reload: () => loadImageSequence(props.url),
play: startAnimation,
pause: stopAnimation,
reset: resetAnimation,
toggle: togglePlayback,
resume: resumeFromPause,
/** 立即跳转,会打断当前动画。 */
jumpTo,
/** 在当前动画循环结束后跳转,返回一个Promise。 */
jumpToSoftly,
drawFrame: (frameIndex: number) => drawFrame(frameIndex),
findRuleIndex,
getRules: () => props.rules,
getCurrentRule: () => props.rules[currentRuleIndex.value],
getRuleByName: (name: string) => props.rules.find(rule => rule.name === name)
})
</script>
<style scoped>
canvas {
display: block;
width: auto;
margin: 0 auto;
height: 100%;
object-fit: contain;
}
canvas.stretch {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
interface AnimationRule {
name: string
frame: number
duration?: number
loop: number
startFrame?: number
endFrame?: number
reverse?: boolean
easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'
pauseAfter?: boolean
}
export type { AnimationRule }
+128
View File
@@ -0,0 +1,128 @@
import JSZip from 'jszip'
import type { AnimationRule } from './types';
import { extractFrameFromVideo } from './vFrameExtractor';
export async function loadFromResSmartly({ url, rules, onprogress }: {
url: { zip?: string, video?: string },
rules: AnimationRule[],
onprogress?: (progress: { download: number, decode: number }) => void
}): Promise<CanvasImageSource[]> {
const isChromium = navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
const useWebCodec = isChromium && 'VideoDecoder' in window;
const finalType =
useWebCodec && url.video ? 'video' :
(url.zip ? 'zip' : 'none');
if (finalType == 'none') throw new Error('No valid resource type provided. Use "zip" or "video".');
return loadFromRes({
url: finalType === 'zip' ? url.zip! : url.video!,
type: finalType,
rules,
onprogress
});
}
export async function loadFromRes({ url, type, rules, onprogress }: {
url: string,
type: 'zip' | 'video',
rules: AnimationRule[],
onprogress?: (progress: { download: number, decode: number }) => void
}): Promise<CanvasImageSource[]> {
if (type === 'zip') {
const urls = await loadzip(url, p => (onprogress?.({ download: p, decode: 0 })));
const images = await loadAllImages(urls);
onprogress?.({ download: 1, decode: 1 });
return images
} else if (type === 'video') {
return extractFrameFromVideo(url, rules?.reduce((acc, rule) => acc + rule.frame, 0), onprogress);
} else {
throw new Error('Unsupported type. Use "zip" or "video".');
}
}
export async function loadzip(url: string, onprogress?: (progress: number) => void): Promise<string[]> {
const zipBlob = await downloadRes(url, (progress) => {
onprogress?.(progress * 0.8);
});
console.log(`加载压缩包: ${url}, 大小: ${(zipBlob.byteLength / 1024).toFixed(2)} KB`);
const zip = new JSZip()
const zipData = await zip.loadAsync(zipBlob)
console.log(`压缩包加载完成,包含文件: ${Object.keys(zipData.files).length} 个文件`);
onprogress?.(60);
const webpFiles = Object.keys(zipData.files)
.filter(name => !zipData.files[name].dir && (name.endsWith('.webp') || name.endsWith('.png') || name.endsWith('.jpg')))
.sort((a, b) => {
const numA = parseInt(a.match(/(\d+)\.\w+$/)?.[1] || '0')
const numB = parseInt(b.match(/(\d+)\.\w+$/)?.[1] || '0')
return numA - numB
})
if (webpFiles.length === 0) {
throw new Error('压缩包中没有找到支持的图片文件 (webp, png, jpg)')
}
const imageUrls: string[] = []
for (let i = 0; i < webpFiles.length; i++) {
const fileName = webpFiles[i]
const file = zipData.files[fileName]
const imageBlob = await file.async('blob')
const blobUrl = URL.createObjectURL(imageBlob)
imageUrls.push(blobUrl)
}
onprogress?.(100);
return imageUrls
}
export async function loadAllImages(urls: string[]) {
const images: HTMLImageElement[] = []
for (const url of urls) {
const img = new Image()
img.src = url
await new Promise((resolve, reject) => {
img.onload = () => resolve(undefined)
img.onerror = () => resolve(undefined)
})
images.push(img)
}
return images
}
// 下载视频文件
export async function downloadRes(url: string, onProgress: (progress: number) => void): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'arraybuffer'
xhr.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = event.loaded / event.total
onProgress(percentComplete)
}
}
xhr.onload = () => {
if (xhr.status !== 200) {
reject(new Error(`Failed to load video: ${xhr.statusText}`))
return
}
if (!xhr.response) {
reject(new Error('Failed to load video data'))
return
}
resolve(xhr.response)
}
xhr.onerror = () => reject(new Error('Network error occurred'))
xhr.send()
})
}
+80
View File
@@ -0,0 +1,80 @@
import { downloadRes } from "../utils"
// 创建 Worker 并处理帧提取
async function extractFramesWithWorker(
blobPath: string,
expectFrames: number,
onProgress: (progress: number, totalSize: number) => void
): Promise<VideoFrame[]> {
let totalSize = 0
return new Promise((resolve, reject) => {
const frames: VideoFrame[] = []
const worker = new Worker(new URL('./worker.js', import.meta.url),
{ type: 'classic' }
)
const handleWorkerMessage = (event: MessageEvent) => {
const data = event.data
if (!data) return
for (const key in data) {
if (key === 'append_frame') {
const frame = data[key] as VideoFrame
frames.push(frame)
totalSize += frame.allocationSize()
onProgress(frames.length / expectFrames, totalSize )
if (frames.length >= expectFrames) {
worker.removeEventListener('message', handleWorkerMessage)
worker.terminate()
resolve(frames)
return
}
} else {
// console.log(`[vFrameExtractor]:`, key, data[key])
}
}
}
worker.addEventListener('message', handleWorkerMessage)
worker.addEventListener('error', (error) => {
worker.terminate()
reject(new Error(`Worker error: ${error.message}`))
})
worker.postMessage({ dataUri: blobPath })
})
}
// 主函数
export async function extractFrameFromVideo(
videoPath: string,
expectFrames: number,
onProgress?: (progress: { download: number, decode: number}) => void
): Promise<VideoFrame[]> {
try {
// 下载视频
const arrayBuffer = await downloadRes(videoPath, (downloadProgress) => {
onProgress?.({ download: downloadProgress, decode: 0})
})
// 创建 Blob 和 URL
const blob = new Blob([arrayBuffer], { type: 'video/mp4' })
const blobPath = URL.createObjectURL(blob)
try {
// 提取帧
const frames = await extractFramesWithWorker(blobPath, expectFrames, (decodeProgress) => {
onProgress?.({ download: 1, decode: decodeProgress })
})
return frames
} finally {
// 清理 URL
URL.revokeObjectURL(blobPath)
}
} catch (error) {
throw error instanceof Error ? error : new Error('Unknown error occurred')
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
import { EasyAudio } from "./utils";
import water_interface from "./Water Interface Button 03.mp3"
import whoosh from "./Whoosh 01.mp3"
import shimmering_light from "./Shimmering Light 02.mp3"
const ea = new EasyAudio([
{ name: "water_interface", audioUrl: water_interface, volume: 0.8 },
{ name: "whoosh", audioUrl: whoosh, volume: 0.9 },
{ name: "shimmering_light", audioUrl: shimmering_light, volume: 0.9 },
]);
export default ea;
+114
View File
@@ -0,0 +1,114 @@
export class EasyAudio {
private static audioCtx: AudioContext | null = null;
private buffers: Map<string, AudioBuffer> = new Map();
private sources: Map<string, AudioBufferSourceNode> = new Map();
private audioOptions: Map<
string,
{ audioUrl: string; loop?: boolean; volume?: number }
> = new Map();
private get audioCtx(): AudioContext {
if (!EasyAudio.audioCtx) {
EasyAudio.audioCtx = new AudioContext();
}
return EasyAudio.audioCtx;
}
constructor(
audios?:
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
) {
if (audios) {
this.add(audios);
}
}
private async createAudioBuffer(audioUrl: string): Promise<AudioBuffer> {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
return await this.audioCtx.decodeAudioData(arrayBuffer);
}
private async loadAudio(name: string) {
const options = this.audioOptions.get(name);
if (!options) {
throw new Error(`音频 ${name} 未找到`);
}
const buffer = await this.createAudioBuffer(options.audioUrl);
this.buffers.set(name, buffer);
}
async load(): Promise<void> {
const promises = Array.from(this.audioOptions.keys()).map(name =>
this.loadAudio(name)
);
await Promise.all(promises);
}
add(
audios:
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
): void {
if (Array.isArray(audios)) {
audios.forEach(audio => {
this.audioOptions.set(audio.name, {
audioUrl: typeof (audio.audioUrl) === "string" ? audio.audioUrl : audio.audioUrl.href,
loop: audio.loop,
volume: audio.volume,
});
});
} else {
this.audioOptions.set(audios.name, {
audioUrl: typeof (audios.audioUrl) === "string" ? audios.audioUrl : audios.audioUrl.href,
loop: audios.loop,
volume: audios.volume,
});
}
}
async play(name: string) {
if (!this.buffers.has(name)) {
await this.loadAudio(name);
}
const buffer = this.buffers.get(name);
const options = this.audioOptions.get(name);
if (!buffer || !options) {
throw new Error(`音频 ${name} 未找到`);
}
const source = this.audioCtx.createBufferSource();
source.buffer = buffer;
source.loop = options.loop || false;
let gainNode: GainNode | null = null;
if (options.volume !== undefined) {
gainNode = this.audioCtx.createGain();
gainNode.gain.value = options.volume;
source.connect(gainNode);
gainNode.connect(this.audioCtx.destination);
} else {
source.connect(this.audioCtx.destination);
}
source.start();
this.sources.set(name, source);
console.log(`音频 ${name} 播放`);
}
stop(name: string) {
const source = this.sources.get(name);
if (source) {
source.stop();
this.sources.delete(name);
}
}
stopAll() {
this.sources.forEach(source => source.stop());
this.sources.clear();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
+34
View File
@@ -0,0 +1,34 @@
body{
margin: 0;
}
* {
box-sizing: border-box;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-in-enter-active,
.slide-in-leave-active {
transition: transform 0.5s ease;
}
.slide-in-enter-from,
.slide-in-leave-to {
transform: translateX(100%);
}
#app{
height: 100vh;
width: 100vw;
overflow: hidden;
}
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="loader">
<div class="progress">
<div class="progress-bar-download" :style="{ width: `${progress.download * 100}%` }"></div>
<div class="progress-bar-decode" :style="{ width: `${progress.decode * 100}%` }"></div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
progress: { download: number, decode: number }
}>();
</script>
<style scoped>
.loader {
display: flex;
justify-content: center;
align-items: center;
height: 100vmin;
width: 100%;
pointer-events: none;
}
.progress {
position: relative;
height: 1.6vmin;
width: 60%;
background: #eee;
border-radius: .8vmin;
overflow: hidden;
}
.progress-bar-download,
.progress-bar-decode {
position: absolute;
height: 100%;
transition: width 0.2s;
}
.progress-bar-download {
background: #5A335A99;
}
.progress-bar-decode {
background: #5A335A;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare global {
interface Window {
takeHeapSnapshot?: () => void;
vframes: VideoFrame[];
}
}