Skip to content

Commit

Permalink
preview for animation & sprite (goplus#1280)
Browse files Browse the repository at this point in the history
  • Loading branch information
nighca authored Feb 6, 2025
1 parent eb35abb commit 4e5e091
Show file tree
Hide file tree
Showing 14 changed files with 378 additions and 148 deletions.
17 changes: 15 additions & 2 deletions spx-gui/src/components/asset/library/SpriteItem.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
<template>
<UISpriteItem :selectable="{ selected }" :name="asset.displayName">
<UISpriteItem ref="wrapperRef" :selectable="{ selected }" :name="asset.displayName">
<template #img="{ style }">
<UIImg :style="style" :src="imgSrc" :loading="imgLoading" />
<CostumesAutoPlayer
v-if="animation != null && hovered"
:style="style"
:costumes="animation.costumes"
:duration="animation.duration"
:placeholder-img="imgSrc"
/>
<UIImg v-else :style="style" :src="imgSrc" :loading="imgLoading" />
</template>
</UISpriteItem>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { UIImg, UISpriteItem } from '@/components/ui'
import { useFileUrl } from '@/utils/file'
import type { AssetData } from '@/apis/asset'
import { asset2Sprite } from '@/models/common/asset'
import { useAsyncComputed } from '@/utils/utils'
import { useHovered } from '@/utils/dom'
import CostumesAutoPlayer from '@/components/common/CostumesAutoPlayer.vue'
const props = defineProps<{
asset: AssetData
Expand All @@ -20,4 +30,7 @@ const props = defineProps<{
const sprite = useAsyncComputed(() => asset2Sprite(props.asset))
const [imgSrc, imgLoading] = useFileUrl(() => sprite.value?.defaultCostume?.img)
const wrapperRef = ref<InstanceType<typeof UISpriteItem>>()
const hovered = useHovered(() => wrapperRef.value?.$el ?? null)
const animation = computed(() => sprite.value?.getDefaultAnimation() ?? null)
</script>
39 changes: 35 additions & 4 deletions spx-gui/src/components/asset/scratch/SpriteItem.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
<template>
<UISpriteItem :selectable="{ selected }" :name="asset.name">
<UISpriteItem ref="wrapperRef" :selectable="{ selected }" :name="asset.name">
<template #img="{ style }">
<UIImg :style="style" :src="imgSrc" :loading="imgSrc == null" />
<CostumesAutoPlayer
v-if="animation != null && hovered"
:style="style"
:costumes="animation.costumes"
:duration="animation.duration"
:placeholder-img="imgSrc"
/>
<UIImg v-else :style="style" :src="imgSrc" :loading="imgSrc == null" />
</template>
</UISpriteItem>
</template>

<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import { UIImg, UISpriteItem } from '@/components/ui'
import type { ExportedScratchSprite } from '@/utils/scratch'
import { useHovered } from '@/utils/dom'
import type { ExportedScratchFile, ExportedScratchSprite } from '@/utils/scratch'
import { fromBlob } from '@/models/common/file'
import { Costume } from '@/models/costume'
import { defaultFps } from '@/models/animation'
import CostumesAutoPlayer from '@/components/common/CostumesAutoPlayer.vue'
const props = defineProps<{
asset: ExportedScratchSprite
Expand All @@ -27,4 +39,23 @@ watchEffect((onCleanup) => {
onCleanup(() => URL.revokeObjectURL(url))
})
const wrapperRef = ref<InstanceType<typeof UISpriteItem>>()
const hovered = useHovered(() => wrapperRef.value?.$el ?? null)
function adaptCostume(c: ExportedScratchFile) {
const file = fromBlob(c.name, c.blob)
return new Costume(c.name, file, {
bitmapResolution: c.bitmapResolution
})
}
const animation = computed(() => {
const costumes = props.asset.costumes
if (costumes.length <= 1) return null
return {
costumes: costumes.map(adaptCostume),
duration: costumes.length / defaultFps
}
})
</script>
31 changes: 31 additions & 0 deletions spx-gui/src/components/common/CostumesAutoPlayer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { useMessageHandle } from '@/utils/exception'
import { getCleanupSignal, type OnCleanup } from '@/utils/disposable'
import type { Costume } from '@/models/costume'
import CostumesPlayer from './CostumesPlayer.vue'
const props = defineProps<{
costumes: Costume[]
/** Duration (in seconds) for all costumes to be played once */
duration: number
placeholderImg?: string | null
}>()
const playerRef = ref<InstanceType<typeof CostumesPlayer>>()
const loadAndPlay = useMessageHandle(async (onCleanup: OnCleanup) => {
const player = playerRef.value
if (player == null) return
const signal = getCleanupSignal(onCleanup)
const { costumes, duration } = props
await player.load(costumes, duration, signal)
player.play(signal)
}).fn
watchEffect(loadAndPlay)
</script>

<template>
<CostumesPlayer ref="playerRef" :placeholder-img="placeholderImg" />
</template>
131 changes: 131 additions & 0 deletions spx-gui/src/components/common/CostumesPlayer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref } from 'vue'
import { File } from '@/models/common/file'
import type { Costume } from '@/models/costume'
import { UIImg } from '../ui'
const props = defineProps<{
placeholderImg?: string | null
}>()
type Frame = {
img: HTMLImageElement
width: number
height: number
x: number
y: number
}
const canvasRef = ref<HTMLCanvasElement>()
async function loadImg(file: File, signal: AbortSignal) {
const url = await file.url((f) => signal.addEventListener('abort', f))
const img = new Image()
img.src = url
await img.decode().catch((e) => {
// Sometimes `decode` fails, while the image is still able to be displayed
console.warn('Failed to decode image', url, e)
})
return img
}
async function loadFrame(costume: Costume, signal: AbortSignal): Promise<Frame> {
const [img, size] = await Promise.all([loadImg(costume.img, signal), costume.getSize()])
const x = costume.x / costume.bitmapResolution
const y = costume.y / costume.bitmapResolution
return { img, x, y, ...size }
}
async function loadFrames(costumes: Costume[], signal: AbortSignal) {
return Promise.all(costumes.map((costume) => loadFrame(costume, signal)))
}
const drawingOptionsRef = ref({
scale: 1,
offsetX: 0,
offsetY: 0
})
function adjustDrawingOptions(canvas: HTMLCanvasElement, firstFrame: Frame) {
const scale = Math.min(canvas.width / firstFrame.width, canvas.height / firstFrame.height)
drawingOptionsRef.value = {
scale,
offsetX: (canvas.width - firstFrame.width * scale) / 2,
offsetY: (canvas.height - firstFrame.height * scale) / 2
}
}
function drawFrame(canvas: HTMLCanvasElement, frame: Frame) {
const ctx = canvas.getContext('2d')!
const { scale, offsetX, offsetY } = drawingOptionsRef.value
const x = offsetX - frame.x * scale
const y = offsetY - frame.y * scale
const width = frame.width * scale
const height = frame.height * scale
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(frame.img, x, y, width, height)
}
function playFrames(frames: Frame[], duration: number, signal: AbortSignal) {
const canvas = canvasRef.value
if (canvas == null) return
const dpr = window.devicePixelRatio
canvas.width = Math.floor(canvas.clientWidth * dpr)
canvas.height = Math.floor(canvas.clientHeight * dpr)
if (frames.length === 0) return
adjustDrawingOptions(canvas, frames[0])
const interval = (duration * 1000) / frames.length
let currIdx = 0
drawFrame(canvas, frames[currIdx])
const timer = setInterval(() => {
currIdx = (currIdx + 1) % frames.length
drawFrame(canvas, frames[currIdx])
}, interval)
signal.addEventListener('abort', () => clearInterval(timer))
}
type Loaded = {
frames: Frame[]
duration: number
}
const loadedRef = ref<Loaded | null>(null)
async function load(costumes: Costume[], duration: number, signal: AbortSignal) {
const frames = await loadFrames(costumes, signal)
signal.throwIfAborted()
loadedRef.value = { frames, duration }
}
async function play(signal: AbortSignal) {
if (loadedRef.value == null) throw new Error('not loaded yet')
const { frames, duration } = loadedRef.value!
playFrames(frames, duration, signal)
}
defineExpose({ load, play })
</script>

<template>
<div class="frames-player">
<canvas ref="canvasRef" class="canvas"></canvas>
<UIImg
v-show="props.placeholderImg != null && loadedRef == null"
class="placeholder"
:src="props.placeholderImg ?? null"
loading
/>
</div>
</template>

<style lang="scss" scoped>
.frames-player {
position: relative;
}
.canvas,
.placeholder {
position: absolute;
width: 100%;
height: 100%;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ const resourceModel = computed(() => getResourceModel(codeEditorCtx.ui.project,
</script>

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

<style lang="scss" scoped></style>
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,33 @@ withDefaults(
defineProps<{
resource: ResourceModel
selectable?: false | { selected: boolean }
autoplay?: boolean
}>(),
{
selectable: false
selectable: false,
autoplay: false
}
)
</script>

<template>
<AnimationItem v-if="resource instanceof Animation" :animation="resource" :selectable="selectable" color="primary" />
<AnimationItem
v-if="resource instanceof Animation"
:animation="resource"
:selectable="selectable"
color="primary"
:autoplay="autoplay"
/>
<BackdropItem v-else-if="resource instanceof Backdrop" :backdrop="resource" :selectable="selectable" />
<CostumeItem v-else-if="resource instanceof Costume" :costume="resource" :selectable="selectable" color="primary" />
<SoundItem v-else-if="resource instanceof Sound" :sound="resource" :selectable="selectable" color="primary" />
<SpriteItem v-else-if="resource instanceof Sprite" :sprite="resource" :selectable="selectable" color="primary" />
<SpriteItem
v-else-if="resource instanceof Sprite"
:sprite="resource"
:selectable="selectable"
color="primary"
:autoplay="autoplay"
/>
<WidgetItem v-else-if="isWidget(resource)" :widget="resource" :selectable="selectable" color="primary" />
</template>

Expand Down
24 changes: 19 additions & 5 deletions spx-gui/src/components/editor/sprite/AnimationItem.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
<template>
<UIEditorSpriteItem :selectable="selectable" :name="animation.name" :color="color">
<UIEditorSpriteItem ref="wrapperRef" :selectable="selectable" :name="animation.name" :color="color">
<template #img="{ style }">
<UIImg :style="style" :src="imgSrc" :loading="imgLoading" />
<CostumesAutoPlayer
v-if="autoplay || hovered"
:style="style"
:costumes="animation.costumes"
:duration="animation.duration"
:placeholder-img="imgSrc"
/>
<UIImg v-else :style="style" :src="imgSrc" :loading="imgLoading" />
</template>
<UICornerIcon v-if="removable" type="trash" :color="color" @click="handleRemove" />
</UIEditorSpriteItem>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { UIImg, UIEditorSpriteItem, useModal, UICornerIcon } from '@/components/ui'
import { useFileUrl } from '@/utils/file'
import { useEditorCtx } from '../EditorContextProvider.vue'
import { useHovered } from '@/utils/dom'
import type { Animation } from '@/models/animation'
import { useMessageHandle } from '@/utils/exception'
import CostumesAutoPlayer from '@/components/common/CostumesAutoPlayer.vue'
import { useEditorCtx } from '../EditorContextProvider.vue'
import AnimationRemoveModal from './AnimationRemoveModal.vue'
const props = withDefaults(
Expand All @@ -22,18 +31,23 @@ const props = withDefaults(
color?: 'sprite' | 'primary'
selectable?: false | { selected: boolean }
removable?: boolean
autoplay?: boolean
}>(),
{
color: 'sprite',
selectable: false,
removable: false
removable: false,
autoplay: false
}
)
const editorCtx = useEditorCtx()
const [imgSrc, imgLoading] = useFileUrl(() => props.animation.costumes[0].img)
const removable = computed(() => props.removable && props.selectable && props.selectable.selected)
const wrapperRef = ref<InstanceType<typeof UIEditorSpriteItem>>()
const hovered = useHovered(() => wrapperRef.value?.$el ?? null)
const removeAnimation = useModal(AnimationRemoveModal)
const handleRemove = useMessageHandle(
() =>
Expand Down
Loading

0 comments on commit 4e5e091

Please sign in to comment.