init
This commit is contained in:
+274
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
@@ -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 |
@@ -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.
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
takeHeapSnapshot?: () => void;
|
||||
vframes: VideoFrame[];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user