注册
web

如何将canvas动画导成一个视频?

引言


某一天我突然有个想法,我想用canvas做一个音频可视化的音谱,然后将这个音频导出成视频。


使用canvas实现音频可视化,使用ffmpeg导出视频与音频,看起来方案是可行的,技术也是可行的,说干就干,先写一个demo。


这里我使用vue来搭建项目



  • 创建项目

vue create demo


  • 安装ffmpeg插件

npm @ffmpeg/ffmpeg @ffmpeg/core


  • 组件videoPlayer.vue
    这里有个点需要注意:引用@ffmpeg/ffmpeg可能会报错
    需要将node_modules中@ffmpeg文件下面的
  • ffmpeg-core.js
  • ffmpeg-core.wasm
  • ffmpeg-core.worker.js
    这三个文件复制到public文件下面
  • 并且需要在vue。config.js中进行如下配置

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
devServer:{
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
}
}
})

准备好这些后,下面是实现代码


<template>
    <div class="wrap" v-loading="loading" element-loading-text="正在下载视频。。。">
        <div>
            <input type="file" @change="handleFileUpload" accept="audio/*" />
            <button @click="playAudio">播放</button>
            <button @click="pauseAudio">暂停</button>
        </div>
        <div class="canvas-wrap">
            <canvas ref="canvas" id="canvas"></canvas>
        </div>
    </div>
</template>



<script>
import RainDrop from './rain'
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
export default {
    name: 'canvasVideo',
    data() {
        return {
            frames: [],
            recording: false,
            ffmpeg: null,
            x: 0,
            loading: false,
            canvasCtx: null,
            audioContext: null,
            analyser: null,
            bufferLength: null,
            dataArray: null,
            audioFile: null,
            audioElement: null,
            audioSource: null,
            // 谱频个数
            barCount: 64,
            // 宽度
            barWidth: 10,
            marginLeft: 10,
            player: false,
            rainCount: 200,
            rainDrops: [],
            pausePng: null,
            offscreenCanvas: null
        };
    },
    mounted() {
        this.ffmpeg = createFFmpeg({ log: true });
        this.initFFmpeg();
    },
    methods: {
        async initFFmpeg() {
            await this.ffmpeg.load();
            this.initCanvas()
        },
        startRecording() {
            this.recording = true;
            this.captureFrames();
        },
        stopRecording() {
            this.recording = false;
            this.exportVideo();

        },

        async captureFrames() {
            const canvas = this.canvasCtx.canvas;
            const imageData = canvas.toDataURL('image/png');
            this.frames.push(imageData);
        },
        async exportVideo() {
            this.loading = true
            this.recording = false
            const { ffmpeg } = this;
            console.log('frames', this.frames)
            try {
                for (let i = 0; i < this.frames.length; i++) {
                    const frame = this.frames[i];
                    const frameData = await fetchFile(frame);
                    ffmpeg.FS('writeFile', `frame${i}.png`, frameData);
                }
                // 将音频文件写入 FFmpeg 文件系统
                ffmpeg.FS('writeFile', 'audio.mp3', await fetchFile(this.audioFile));
                // 使用 FFmpeg 将帧编码为视频
                await ffmpeg.run(
                    '-framerate', '30', // 帧率 可以收费
                    '-i', 'frame%d.png', // 输入文件名格式
                    '-i', 'audio.mp3', // 输入音频
                    '-c:v', 'libx264', // 视频编码器
                    '-c:a', 'aac', // 音频编码器
                    '-pix_fmt', 'yuv420p', // 像素格式
                    '-vsync', 'vfr', // 同步视频和音频
                    '-shortest', // 使视频长度与音频一致
                    'output.mp4' // 输出文件名
                );
                const files = ffmpeg.FS('readdir', '/');
                console.log('文件系统中的文件:', files);
                const data = ffmpeg.FS('readFile', 'output.mp4');
                const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
                const a = document.createElement('a');
                a.href = url;
                a.download = 'output.mp4';
                a.click();
            } catch (e) {
                console.log('eeee', e)
            }
            this.loading = false
        },
        initCanvas() {
            const dom = document.getElementById('canvas');
            this.canvasCtx = dom.getContext('2d');
            const p = document.querySelector('.canvas-wrap')
            console.log('p', p.offsetWidth)
            this.canvasCtx.canvas.width = p.offsetWidth;
            this.canvasCtx.canvas.height = p.offsetHeight;
            console.log('canvasCtx', this.canvasCtx)
            this.initAudioContext()
            this.createRainDrops()
        },
        handleFileUpload(event) {
            const file = event.target.files[0];
            if (file) {
                this.audioFile = file
                const fileURL = URL.createObjectURL(file);
                this.loadAudio(fileURL);
            }
        },
        loadAudio(url) {
            this.audioElement = new Audio(url);
            this.audioElement.addEventListener('error', (e) => {
                console.error('音频加载失败:', e);
            });
            this.audioSource = this.audioContext.createMediaElementSource(this.audioElement);
            this.audioSource.connect(this.analyser);
            this.analyser.connect(this.audioContext.destination);
        },
        playAudio() {
            if (this.audioContext.state === 'suspended') {
                this.audioContext.resume().then(() => {
                    console.log('AudioContext 已恢复');
                    this.audioElement.play();
                    this.player = true
                    this.draw();
                });
            } else {
                this.audioElement.play().then(() => {
                    this.player = true
                    this.draw();
                }).catch((error) => {
                    console.error('播放失败:', error);
                });
            }
        },
        pauseAudio() {
            if (this.audioElement) {
                this.audioElement.pause();
                this.player = false
                this.stopRecording()
            }
        },
        initAudioContext() {
            this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
            this.analyser = this.audioContext.createAnalyser();
            this.analyser.fftSize = 256;
            this.dataArray = new Uint8Array(this.barCount);
        },
        bar() {
            let barHeight = 20;
            const allBarWidth = this.barCount * this.barWidth + this.marginLeft * (this.barCount - 1)
            const left = (this.canvasCtx.canvas.width - allBarWidth) / 2
            let x = left
            for (let i = 0; i < this.barCount; i++) {
                barHeight = this.player ? this.dataArray[i] : 0
                // console.log('barHeight', barHeight)
                // 创建线性渐变
                const gradient = this.canvasCtx.createLinearGradient(0, 0, this.canvasCtx.canvas.width, 0); // 从左到右渐变
                gradient.addColorStop(0.2, '#fff');    // 起始颜色
                gradient.addColorStop(0.5, '#ff5555');
                gradient.addColorStop(0.8, '#fff');  // 结束颜色
                // 设置阴影属性
                this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
                this.canvasCtx.shadowBlur = 5;
                this.canvasCtx.fillStyle = gradient;
                this.canvasCtx.fillRect(x, this.canvasCtx.canvas.height - barHeight / 2 - 100, this.barWidth, barHeight / 2);
                this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
                this.canvasCtx.shadowBlur = 5;
                this.canvasCtx.beginPath();
                this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2 - 99, 5, 0, Math.PI, true)
                // this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2, 5, 0, Math.PI, false)
                this.canvasCtx.closePath();
                this.canvasCtx.fill()
                this.canvasCtx.shadowColor = i <= 10 ? '#fff' : i > 54 ? '#fff' : '#ff5555';
                this.canvasCtx.shadowBlur = 5;
                this.canvasCtx.beginPath();
                // this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - barHeight / 2 - 100, 5, 0, Math.PI, true)
                this.canvasCtx.arc(x + 5, this.canvasCtx.canvas.height - 100, 5, 0, Math.PI, false)
                this.canvasCtx.closePath();
                this.canvasCtx.fill()
                x += this.barWidth + this.marginLeft;
            }
        },
        draw() {
            if (this.player) requestAnimationFrame(this.draw);
            this.startRecording()
            // 获取频谱数据
            this.analyser.getByteFrequencyData(this.dataArray);
            this.canvasCtx.fillStyle = 'rgb(0, 0, 0)';
            this.canvasCtx.fillRect(0, 0, this.canvasCtx.canvas.width, this.canvasCtx.canvas.height); // 清除画布
            this.bar()
            this.rainDrops.forEach((drop) => {
                drop.update();
                drop.draw(this.canvasCtx);
            });
        },
        // 创建雨滴对象
        createRainDrops() {
            for (let i = 0; i < this.rainCount; i++) {
                this.rainDrops.push(new RainDrop(this.canvasCtx.canvas.width, this.canvasCtx.canvas.height, this.canvasCtx));
            }
        },
    }
};
</script>


当选择好音频文件点击播放时如下图


屏幕截图 2025-07-01 100029.png


点击暂停则可对已经播放过的音频时长进行视频录制下载


2.png


如果有什么其他问题欢迎在评论区交流


作者:NeverSettle110574
来源:juejin.cn/post/7521685642431053863

0 个评论

要回复文章请先登录注册