Skip to content

Commit 4e5e091

Browse files
authored
preview for animation & sprite (goplus#1280)
1 parent eb35abb commit 4e5e091

File tree

14 files changed

+378
-148
lines changed

14 files changed

+378
-148
lines changed
Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
<template>
2-
<UISpriteItem :selectable="{ selected }" :name="asset.displayName">
2+
<UISpriteItem ref="wrapperRef" :selectable="{ selected }" :name="asset.displayName">
33
<template #img="{ style }">
4-
<UIImg :style="style" :src="imgSrc" :loading="imgLoading" />
4+
<CostumesAutoPlayer
5+
v-if="animation != null && hovered"
6+
:style="style"
7+
:costumes="animation.costumes"
8+
:duration="animation.duration"
9+
:placeholder-img="imgSrc"
10+
/>
11+
<UIImg v-else :style="style" :src="imgSrc" :loading="imgLoading" />
512
</template>
613
</UISpriteItem>
714
</template>
815

916
<script setup lang="ts">
17+
import { computed, ref } from 'vue'
1018
import { UIImg, UISpriteItem } from '@/components/ui'
1119
import { useFileUrl } from '@/utils/file'
1220
import type { AssetData } from '@/apis/asset'
1321
import { asset2Sprite } from '@/models/common/asset'
1422
import { useAsyncComputed } from '@/utils/utils'
23+
import { useHovered } from '@/utils/dom'
24+
import CostumesAutoPlayer from '@/components/common/CostumesAutoPlayer.vue'
1525
1626
const props = defineProps<{
1727
asset: AssetData
@@ -20,4 +30,7 @@ const props = defineProps<{
2030
2131
const sprite = useAsyncComputed(() => asset2Sprite(props.asset))
2232
const [imgSrc, imgLoading] = useFileUrl(() => sprite.value?.defaultCostume?.img)
33+
const wrapperRef = ref<InstanceType<typeof UISpriteItem>>()
34+
const hovered = useHovered(() => wrapperRef.value?.$el ?? null)
35+
const animation = computed(() => sprite.value?.getDefaultAnimation() ?? null)
2336
</script>

spx-gui/src/components/asset/scratch/SpriteItem.vue

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
<template>
2-
<UISpriteItem :selectable="{ selected }" :name="asset.name">
2+
<UISpriteItem ref="wrapperRef" :selectable="{ selected }" :name="asset.name">
33
<template #img="{ style }">
4-
<UIImg :style="style" :src="imgSrc" :loading="imgSrc == null" />
4+
<CostumesAutoPlayer
5+
v-if="animation != null && hovered"
6+
:style="style"
7+
:costumes="animation.costumes"
8+
:duration="animation.duration"
9+
:placeholder-img="imgSrc"
10+
/>
11+
<UIImg v-else :style="style" :src="imgSrc" :loading="imgSrc == null" />
512
</template>
613
</UISpriteItem>
714
</template>
815

916
<script setup lang="ts">
10-
import { ref, watchEffect } from 'vue'
17+
import { computed, ref, watchEffect } from 'vue'
1118
import { UIImg, UISpriteItem } from '@/components/ui'
12-
import type { ExportedScratchSprite } from '@/utils/scratch'
19+
import { useHovered } from '@/utils/dom'
20+
import type { ExportedScratchFile, ExportedScratchSprite } from '@/utils/scratch'
21+
import { fromBlob } from '@/models/common/file'
22+
import { Costume } from '@/models/costume'
23+
import { defaultFps } from '@/models/animation'
24+
import CostumesAutoPlayer from '@/components/common/CostumesAutoPlayer.vue'
1325
1426
const props = defineProps<{
1527
asset: ExportedScratchSprite
@@ -27,4 +39,23 @@ watchEffect((onCleanup) => {
2739
2840
onCleanup(() => URL.revokeObjectURL(url))
2941
})
42+
43+
const wrapperRef = ref<InstanceType<typeof UISpriteItem>>()
44+
const hovered = useHovered(() => wrapperRef.value?.$el ?? null)
45+
46+
function adaptCostume(c: ExportedScratchFile) {
47+
const file = fromBlob(c.name, c.blob)
48+
return new Costume(c.name, file, {
49+
bitmapResolution: c.bitmapResolution
50+
})
51+
}
52+
53+
const animation = computed(() => {
54+
const costumes = props.asset.costumes
55+
if (costumes.length <= 1) return null
56+
return {
57+
costumes: costumes.map(adaptCostume),
58+
duration: costumes.length / defaultFps
59+
}
60+
})
3061
</script>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script setup lang="ts">
2+
import { ref, watchEffect } from 'vue'
3+
import { useMessageHandle } from '@/utils/exception'
4+
import { getCleanupSignal, type OnCleanup } from '@/utils/disposable'
5+
import type { Costume } from '@/models/costume'
6+
import CostumesPlayer from './CostumesPlayer.vue'
7+
8+
const props = defineProps<{
9+
costumes: Costume[]
10+
/** Duration (in seconds) for all costumes to be played once */
11+
duration: number
12+
placeholderImg?: string | null
13+
}>()
14+
15+
const playerRef = ref<InstanceType<typeof CostumesPlayer>>()
16+
17+
const loadAndPlay = useMessageHandle(async (onCleanup: OnCleanup) => {
18+
const player = playerRef.value
19+
if (player == null) return
20+
const signal = getCleanupSignal(onCleanup)
21+
const { costumes, duration } = props
22+
await player.load(costumes, duration, signal)
23+
player.play(signal)
24+
}).fn
25+
26+
watchEffect(loadAndPlay)
27+
</script>
28+
29+
<template>
30+
<CostumesPlayer ref="playerRef" :placeholder-img="placeholderImg" />
31+
</template>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<script setup lang="ts">
2+
import { ref } from 'vue'
3+
import { File } from '@/models/common/file'
4+
import type { Costume } from '@/models/costume'
5+
import { UIImg } from '../ui'
6+
7+
const props = defineProps<{
8+
placeholderImg?: string | null
9+
}>()
10+
11+
type Frame = {
12+
img: HTMLImageElement
13+
width: number
14+
height: number
15+
x: number
16+
y: number
17+
}
18+
19+
const canvasRef = ref<HTMLCanvasElement>()
20+
21+
async function loadImg(file: File, signal: AbortSignal) {
22+
const url = await file.url((f) => signal.addEventListener('abort', f))
23+
const img = new Image()
24+
img.src = url
25+
await img.decode().catch((e) => {
26+
// Sometimes `decode` fails, while the image is still able to be displayed
27+
console.warn('Failed to decode image', url, e)
28+
})
29+
return img
30+
}
31+
32+
async function loadFrame(costume: Costume, signal: AbortSignal): Promise<Frame> {
33+
const [img, size] = await Promise.all([loadImg(costume.img, signal), costume.getSize()])
34+
const x = costume.x / costume.bitmapResolution
35+
const y = costume.y / costume.bitmapResolution
36+
return { img, x, y, ...size }
37+
}
38+
39+
async function loadFrames(costumes: Costume[], signal: AbortSignal) {
40+
return Promise.all(costumes.map((costume) => loadFrame(costume, signal)))
41+
}
42+
43+
const drawingOptionsRef = ref({
44+
scale: 1,
45+
offsetX: 0,
46+
offsetY: 0
47+
})
48+
49+
function adjustDrawingOptions(canvas: HTMLCanvasElement, firstFrame: Frame) {
50+
const scale = Math.min(canvas.width / firstFrame.width, canvas.height / firstFrame.height)
51+
drawingOptionsRef.value = {
52+
scale,
53+
offsetX: (canvas.width - firstFrame.width * scale) / 2,
54+
offsetY: (canvas.height - firstFrame.height * scale) / 2
55+
}
56+
}
57+
58+
function drawFrame(canvas: HTMLCanvasElement, frame: Frame) {
59+
const ctx = canvas.getContext('2d')!
60+
const { scale, offsetX, offsetY } = drawingOptionsRef.value
61+
const x = offsetX - frame.x * scale
62+
const y = offsetY - frame.y * scale
63+
const width = frame.width * scale
64+
const height = frame.height * scale
65+
ctx.clearRect(0, 0, canvas.width, canvas.height)
66+
ctx.drawImage(frame.img, x, y, width, height)
67+
}
68+
69+
function playFrames(frames: Frame[], duration: number, signal: AbortSignal) {
70+
const canvas = canvasRef.value
71+
if (canvas == null) return
72+
const dpr = window.devicePixelRatio
73+
canvas.width = Math.floor(canvas.clientWidth * dpr)
74+
canvas.height = Math.floor(canvas.clientHeight * dpr)
75+
if (frames.length === 0) return
76+
adjustDrawingOptions(canvas, frames[0])
77+
const interval = (duration * 1000) / frames.length
78+
let currIdx = 0
79+
drawFrame(canvas, frames[currIdx])
80+
const timer = setInterval(() => {
81+
currIdx = (currIdx + 1) % frames.length
82+
drawFrame(canvas, frames[currIdx])
83+
}, interval)
84+
signal.addEventListener('abort', () => clearInterval(timer))
85+
}
86+
87+
type Loaded = {
88+
frames: Frame[]
89+
duration: number
90+
}
91+
92+
const loadedRef = ref<Loaded | null>(null)
93+
94+
async function load(costumes: Costume[], duration: number, signal: AbortSignal) {
95+
const frames = await loadFrames(costumes, signal)
96+
signal.throwIfAborted()
97+
loadedRef.value = { frames, duration }
98+
}
99+
100+
async function play(signal: AbortSignal) {
101+
if (loadedRef.value == null) throw new Error('not loaded yet')
102+
const { frames, duration } = loadedRef.value!
103+
playFrames(frames, duration, signal)
104+
}
105+
106+
defineExpose({ load, play })
107+
</script>
108+
109+
<template>
110+
<div class="frames-player">
111+
<canvas ref="canvasRef" class="canvas"></canvas>
112+
<UIImg
113+
v-show="props.placeholderImg != null && loadedRef == null"
114+
class="placeholder"
115+
:src="props.placeholderImg ?? null"
116+
loading
117+
/>
118+
</div>
119+
</template>
120+
121+
<style lang="scss" scoped>
122+
.frames-player {
123+
position: relative;
124+
}
125+
.canvas,
126+
.placeholder {
127+
position: absolute;
128+
width: 100%;
129+
height: 100%;
130+
}
131+
</style>

spx-gui/src/components/editor/code-editor/ui/markdown/ResourcePreview.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const resourceModel = computed(() => getResourceModel(codeEditorCtx.ui.project,
1414
</script>
1515

1616
<template>
17-
<ResourceItem v-if="resourceModel != null" :resource="resourceModel" />
17+
<!-- TODO: Design specially for `ResourcePreview`, instead of using the same `ResourceItem` as `ResourceSelector` -->
18+
<ResourceItem v-if="resourceModel != null" :resource="resourceModel" autoplay />
1819
</template>
1920

2021
<style lang="scss" scoped></style>

spx-gui/src/components/editor/code-editor/ui/resource/ResourceItem.vue

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,33 @@ withDefaults(
1717
defineProps<{
1818
resource: ResourceModel
1919
selectable?: false | { selected: boolean }
20+
autoplay?: boolean
2021
}>(),
2122
{
22-
selectable: false
23+
selectable: false,
24+
autoplay: false
2325
}
2426
)
2527
</script>
2628

2729
<template>
28-
<AnimationItem v-if="resource instanceof Animation" :animation="resource" :selectable="selectable" color="primary" />
30+
<AnimationItem
31+
v-if="resource instanceof Animation"
32+
:animation="resource"
33+
:selectable="selectable"
34+
color="primary"
35+
:autoplay="autoplay"
36+
/>
2937
<BackdropItem v-else-if="resource instanceof Backdrop" :backdrop="resource" :selectable="selectable" />
3038
<CostumeItem v-else-if="resource instanceof Costume" :costume="resource" :selectable="selectable" color="primary" />
3139
<SoundItem v-else-if="resource instanceof Sound" :sound="resource" :selectable="selectable" color="primary" />
32-
<SpriteItem v-else-if="resource instanceof Sprite" :sprite="resource" :selectable="selectable" color="primary" />
40+
<SpriteItem
41+
v-else-if="resource instanceof Sprite"
42+
:sprite="resource"
43+
:selectable="selectable"
44+
color="primary"
45+
:autoplay="autoplay"
46+
/>
3347
<WidgetItem v-else-if="isWidget(resource)" :widget="resource" :selectable="selectable" color="primary" />
3448
</template>
3549

spx-gui/src/components/editor/sprite/AnimationItem.vue

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
<template>
2-
<UIEditorSpriteItem :selectable="selectable" :name="animation.name" :color="color">
2+
<UIEditorSpriteItem ref="wrapperRef" :selectable="selectable" :name="animation.name" :color="color">
33
<template #img="{ style }">
4-
<UIImg :style="style" :src="imgSrc" :loading="imgLoading" />
4+
<CostumesAutoPlayer
5+
v-if="autoplay || hovered"
6+
:style="style"
7+
:costumes="animation.costumes"
8+
:duration="animation.duration"
9+
:placeholder-img="imgSrc"
10+
/>
11+
<UIImg v-else :style="style" :src="imgSrc" :loading="imgLoading" />
512
</template>
613
<UICornerIcon v-if="removable" type="trash" :color="color" @click="handleRemove" />
714
</UIEditorSpriteItem>
815
</template>
916

1017
<script setup lang="ts">
11-
import { computed } from 'vue'
18+
import { computed, ref } from 'vue'
1219
import { UIImg, UIEditorSpriteItem, useModal, UICornerIcon } from '@/components/ui'
1320
import { useFileUrl } from '@/utils/file'
14-
import { useEditorCtx } from '../EditorContextProvider.vue'
21+
import { useHovered } from '@/utils/dom'
1522
import type { Animation } from '@/models/animation'
1623
import { useMessageHandle } from '@/utils/exception'
24+
import CostumesAutoPlayer from '@/components/common/CostumesAutoPlayer.vue'
25+
import { useEditorCtx } from '../EditorContextProvider.vue'
1726
import AnimationRemoveModal from './AnimationRemoveModal.vue'
1827
1928
const props = withDefaults(
@@ -22,18 +31,23 @@ const props = withDefaults(
2231
color?: 'sprite' | 'primary'
2332
selectable?: false | { selected: boolean }
2433
removable?: boolean
34+
autoplay?: boolean
2535
}>(),
2636
{
2737
color: 'sprite',
2838
selectable: false,
29-
removable: false
39+
removable: false,
40+
autoplay: false
3041
}
3142
)
3243
3344
const editorCtx = useEditorCtx()
3445
const [imgSrc, imgLoading] = useFileUrl(() => props.animation.costumes[0].img)
3546
3647
const removable = computed(() => props.removable && props.selectable && props.selectable.selected)
48+
const wrapperRef = ref<InstanceType<typeof UIEditorSpriteItem>>()
49+
const hovered = useHovered(() => wrapperRef.value?.$el ?? null)
50+
3751
const removeAnimation = useModal(AnimationRemoveModal)
3852
const handleRemove = useMessageHandle(
3953
() =>

0 commit comments

Comments
 (0)