duqing2026 commited on
Commit
ebb2801
·
0 Parent(s):

Initial commit

Browse files
Files changed (6) hide show
  1. .gitignore +8 -0
  2. Dockerfile +24 -0
  3. README.md +75 -0
  4. app.py +22 -0
  5. requirements.txt +2 -0
  6. templates/index.html +594 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ uploads/
4
+ outputs/
5
+ .DS_Store
6
+ .env
7
+ venv/
8
+ .venv/
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies including FFmpeg
6
+ RUN apt-get update && apt-get install -y \
7
+ ffmpeg \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ # Create directories for uploads/outputs
16
+ RUN mkdir -p uploads outputs && chmod 777 uploads outputs
17
+
18
+ # Create a non-root user
19
+ RUN useradd -m -u 1000 user
20
+ USER user
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Audiogram Studio
3
+ emoji: 🎵
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # Audiogram Studio (声波视频生成器)
13
+
14
+ Audiogram Studio 是一个专为创作者设计的工具,可以将音频(播客、音乐、录音)转换为带有动态声波可视化效果的视频,方便在社交媒体(Instagram, TikTok, 朋友圈)上分享。
15
+
16
+ ## ✨ 功能特点
17
+
18
+ - **可视化编辑**:实时预览声波效果。
19
+ - **自定义样式**:支持柱状图、线条、圆形三种波形,可调整颜色和位置。
20
+ - **背景图片**:支持上传自定义背景图。
21
+ - **多比例支持**:一键切换 1:1 (正方), 9:16 (手机竖屏), 16:9 (横屏)。
22
+ - **纯前端生成**:利用 HTML5 Canvas 和 MediaRecorder 技术,保护隐私,生成速度快。
23
+ - **Docker 部署**:支持一键部署到 Hugging Face Spaces。
24
+
25
+ ## 🛠️ 技术栈
26
+
27
+ - **Frontend**: Vue 3, Tailwind CSS, HTML5 Canvas, Web Audio API, MediaRecorder API
28
+ - **Backend**: Python Flask (Static Serving)
29
+ - **Deployment**: Docker
30
+
31
+ ## 🚀 快速开始
32
+
33
+ ### 本地运行
34
+
35
+ 1. 克隆仓库
36
+ ```bash
37
+ git clone https://github.com/duqing26/audiogram-studio.git
38
+ cd audiogram-studio
39
+ ```
40
+
41
+ 2. 安装依赖
42
+ ```bash
43
+ pip install -r requirements.txt
44
+ ```
45
+
46
+ 3. 运行应用
47
+ ```bash
48
+ python app.py
49
+ ```
50
+ 访问 `http://localhost:7860` 即可使用。
51
+
52
+ ### Docker 运行
53
+
54
+ ```bash
55
+ docker build -t audiogram-studio .
56
+ docker run -p 7860:7860 audiogram-studio
57
+ ```
58
+
59
+ ## 📝 使用指南
60
+
61
+ 1. **上传音频**:点击左侧上传按钮选择 MP3 或 WAV 文件。
62
+ 2. **上传背景**:选择一张图片作为视频背景。
63
+ 3. **调整样式**:设置画布比例、波形类型、颜色和位置。
64
+ 4. **预览**:点击"播放预览"查看效果。
65
+ 5. **生成**:点击"开始生成视频",等待音频播放完毕,即可下载 WebM 格式视频。
66
+
67
+ ## 💡 注意事项
68
+
69
+ - 生成的视频为 **WebM** 格式,兼容性良好(Chrome, Firefox, Android)。
70
+ - 如需 MP4 格式,推荐使用在线转换工具或 FFmpeg 转换。
71
+ - 录制过程需要实时播放音频,请勿关闭标签页。
72
+
73
+ ## 📄 License
74
+
75
+ MIT
app.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify, send_file
2
+ import os
3
+ import subprocess
4
+ import uuid
5
+
6
+ app = Flask(__name__)
7
+ app.config['UPLOAD_FOLDER'] = 'uploads'
8
+ app.config['OUTPUT_FOLDER'] = 'outputs'
9
+
10
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
11
+ os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
12
+
13
+ @app.route('/')
14
+ def index():
15
+ return render_template('index.html')
16
+
17
+ @app.route('/health')
18
+ def health():
19
+ return jsonify({"status": "healthy"})
20
+
21
+ if __name__ == '__main__':
22
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask==3.0.0
2
+ gunicorn==21.2.0
templates/index.html ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Audiogram Studio - 声波视频生成器</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
10
+ <style>
11
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
12
+ body {
13
+ font-family: 'Inter', sans-serif;
14
+ background-color: #f3f4f6;
15
+ }
16
+ .canvas-container {
17
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
18
+ }
19
+ /* Custom scrollbar */
20
+ ::-webkit-scrollbar {
21
+ width: 8px;
22
+ }
23
+ ::-webkit-scrollbar-track {
24
+ background: #f1f1f1;
25
+ }
26
+ ::-webkit-scrollbar-thumb {
27
+ background: #888;
28
+ border-radius: 4px;
29
+ }
30
+ ::-webkit-scrollbar-thumb:hover {
31
+ background: #555;
32
+ }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <div id="app" class="min-h-screen flex flex-col">
37
+ <!-- Header -->
38
+ <header class="bg-white shadow-sm z-10">
39
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
40
+ <div class="flex items-center space-x-3">
41
+ <div class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white p-2 rounded-lg">
42
+ <i class="fa-solid fa-wave-square text-xl"></i>
43
+ </div>
44
+ <h1 class="text-2xl font-bold text-gray-900 tracking-tight">Audiogram Studio</h1>
45
+ </div>
46
+ <div class="flex items-center space-x-4">
47
+ <a href="https://github.com/duqing26" target="_blank" class="text-gray-500 hover:text-gray-900 transition-colors">
48
+ <i class="fa-brands fa-github text-xl"></i>
49
+ </a>
50
+ </div>
51
+ </div>
52
+ </header>
53
+
54
+ <!-- Main Content -->
55
+ <main class="flex-grow container mx-auto px-4 py-8 flex flex-col lg:flex-row gap-8">
56
+
57
+ <!-- Sidebar: Controls -->
58
+ <div class="w-full lg:w-1/3 space-y-6">
59
+
60
+ <!-- 1. Uploads -->
61
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 transition-all hover:shadow-md">
62
+ <h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
63
+ <i class="fa-solid fa-cloud-arrow-up mr-2 text-indigo-500"></i> 素材上传
64
+ </h2>
65
+
66
+ <!-- Audio Upload -->
67
+ <div class="mb-4">
68
+ <label class="block text-sm font-medium text-gray-700 mb-2">音频文件 (MP3/WAV)</label>
69
+ <div class="relative group">
70
+ <input type="file" accept="audio/*" @change="handleAudioUpload" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
71
+ <div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center group-hover:border-indigo-500 transition-colors bg-gray-50">
72
+ <div v-if="audioFile" class="text-indigo-600 font-medium truncate">
73
+ <i class="fa-solid fa-music mr-2"></i> {{ audioFile.name }}
74
+ </div>
75
+ <div v-else class="text-gray-500">
76
+ <i class="fa-solid fa-file-audio text-2xl mb-2 block text-gray-400"></i>
77
+ 点击或拖拽上传音频
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Image Upload -->
84
+ <div>
85
+ <label class="block text-sm font-medium text-gray-700 mb-2">背景图片</label>
86
+ <div class="relative group">
87
+ <input type="file" accept="image/*" @change="handleImageUpload" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
88
+ <div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center group-hover:border-indigo-500 transition-colors bg-gray-50">
89
+ <div v-if="bgImageSrc" class="text-indigo-600 font-medium truncate">
90
+ <i class="fa-solid fa-image mr-2"></i> 已加载图片
91
+ </div>
92
+ <div v-else class="text-gray-500">
93
+ <i class="fa-regular fa-image text-2xl mb-2 block text-gray-400"></i>
94
+ 点击或拖拽上传背��
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- 2. Settings -->
102
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 transition-all hover:shadow-md">
103
+ <h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
104
+ <i class="fa-solid fa-sliders mr-2 text-indigo-500"></i> 样式设置
105
+ </h2>
106
+
107
+ <!-- Canvas Size -->
108
+ <div class="mb-4">
109
+ <label class="block text-sm font-medium text-gray-700 mb-2">画布比例</label>
110
+ <div class="grid grid-cols-3 gap-2">
111
+ <button @click="setAspectRatio(1, 1)" :class="{'bg-indigo-600 text-white': aspectRatio === 1, 'bg-gray-100 text-gray-600 hover:bg-gray-200': aspectRatio !== 1}" class="px-3 py-2 rounded-md text-sm font-medium transition-colors">
112
+ 1:1 (正方)
113
+ </button>
114
+ <button @click="setAspectRatio(9, 16)" :class="{'bg-indigo-600 text-white': aspectRatio === 9/16, 'bg-gray-100 text-gray-600 hover:bg-gray-200': aspectRatio !== 9/16}" class="px-3 py-2 rounded-md text-sm font-medium transition-colors">
115
+ 9:16 (手机)
116
+ </button>
117
+ <button @click="setAspectRatio(16, 9)" :class="{'bg-indigo-600 text-white': aspectRatio === 16/9, 'bg-gray-100 text-gray-600 hover:bg-gray-200': aspectRatio !== 16/9}" class="px-3 py-2 rounded-md text-sm font-medium transition-colors">
118
+ 16:9 (横屏)
119
+ </button>
120
+ </div>
121
+ </div>
122
+
123
+ <!-- Wave Type -->
124
+ <div class="mb-4">
125
+ <label class="block text-sm font-medium text-gray-700 mb-2">波形样式</label>
126
+ <select v-model="waveType" class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 p-2 border">
127
+ <option value="bars">柱状图 (Bars)</option>
128
+ <option value="line">线条 (Line)</option>
129
+ <option value="circle">圆形 (Circle)</option>
130
+ </select>
131
+ </div>
132
+
133
+ <!-- Color Picker -->
134
+ <div class="mb-4">
135
+ <label class="block text-sm font-medium text-gray-700 mb-2">波形颜色</label>
136
+ <div class="flex items-center space-x-2">
137
+ <input type="color" v-model="waveColor" class="h-10 w-full rounded cursor-pointer border-0 p-0">
138
+ </div>
139
+ </div>
140
+
141
+ <!-- Position -->
142
+ <div class="mb-4">
143
+ <label class="block text-sm font-medium text-gray-700 mb-2">波形位置 Y轴 ({{ waveY }}%)</label>
144
+ <input type="range" v-model.number="waveY" min="0" max="100" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600">
145
+ </div>
146
+ </div>
147
+
148
+ <!-- 3. Actions -->
149
+ <div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 transition-all hover:shadow-md">
150
+ <h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
151
+ <i class="fa-solid fa-clapperboard mr-2 text-indigo-500"></i> 生成操作
152
+ </h2>
153
+
154
+ <div class="space-y-3">
155
+ <button @click="togglePlay" :disabled="!audioFile"
156
+ class="w-full py-3 rounded-lg font-semibold flex items-center justify-center space-x-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
157
+ :class="isPlaying ? 'bg-yellow-500 hover:bg-yellow-600 text-white' : 'bg-green-500 hover:bg-green-600 text-white'">
158
+ <i class="fa-solid" :class="isPlaying ? 'fa-pause' : 'fa-play'"></i>
159
+ <span>{{ isPlaying ? '暂停预览' : '播放预览' }}</span>
160
+ </button>
161
+
162
+ <button @click="startRecording" :disabled="!audioFile || isRecording"
163
+ class="w-full py-3 rounded-lg font-semibold flex items-center justify-center space-x-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-indigo-600 hover:bg-indigo-700 text-white">
164
+ <i class="fa-solid" :class="isRecording ? 'fa-spinner fa-spin' : 'fa-video'"></i>
165
+ <span>{{ isRecording ? '正在录制...' : '开始生成视频' }}</span>
166
+ </button>
167
+
168
+ <div v-if="isRecording" class="text-center text-sm text-gray-500 mt-2">
169
+ 请等待音频播放结束... ({{ formatTime(currentTime) }} / {{ formatTime(duration) }})
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- Main Area: Canvas Preview -->
176
+ <div class="w-full lg:w-2/3 flex flex-col items-center justify-center bg-gray-100 rounded-xl border border-gray-200 p-4 lg:p-8 min-h-[500px]">
177
+ <div class="canvas-container relative bg-white shadow-2xl rounded-sm overflow-hidden" :style="{width: canvasWidth + 'px', height: canvasHeight + 'px'}">
178
+ <canvas ref="canvas" :width="canvasWidth" :height="canvasHeight" class="block"></canvas>
179
+
180
+ <!-- Overlay Text (Optional) -->
181
+ <!-- <div class="absolute bottom-8 left-8 text-white font-bold text-2xl drop-shadow-md">My Podcast Episode</div> -->
182
+ </div>
183
+
184
+ <div class="mt-6 text-gray-500 text-sm flex items-center">
185
+ <i class="fa-solid fa-info-circle mr-2"></i>
186
+ <span>预览区域即为最终视频效果。建议使用 Chrome 浏览器以获得最佳性能。</span>
187
+ </div>
188
+ </div>
189
+
190
+ </main>
191
+
192
+ <!-- Result Modal -->
193
+ <div v-if="showResultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
194
+ <div class="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6 animate-fade-in-up">
195
+ <div class="flex justify-between items-center mb-4">
196
+ <h3 class="text-xl font-bold text-gray-900">视频生成成功!</h3>
197
+ <button @click="showResultModal = false" class="text-gray-400 hover:text-gray-600">
198
+ <i class="fa-solid fa-times text-xl"></i>
199
+ </button>
200
+ </div>
201
+
202
+ <div class="bg-gray-100 rounded-lg p-4 mb-6 text-center">
203
+ <p class="text-gray-600 mb-2">您的视频已生成 (WebM格式)</p>
204
+ <p class="text-xs text-gray-500">注意:WebM 格式在 Windows/Android 上兼容性良好。如需 MP4,请使用在线工具转换。</p>
205
+ </div>
206
+
207
+ <div class="flex space-x-3">
208
+ <a :href="videoUrl" download="audiogram.webm" class="flex-1 bg-indigo-600 text-white py-3 rounded-lg font-semibold text-center hover:bg-indigo-700 transition-colors">
209
+ <i class="fa-solid fa-download mr-2"></i> 下载视频
210
+ </a>
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ </div>
216
+
217
+ <script>
218
+ const { createApp, ref, onMounted, watch, computed } = Vue;
219
+
220
+ createApp({
221
+ setup() {
222
+ const canvas = ref(null);
223
+ const audioFile = ref(null);
224
+ const bgImageSrc = ref(null);
225
+ const isPlaying = ref(false);
226
+ const isRecording = ref(false);
227
+ const showResultModal = ref(false);
228
+ const videoUrl = ref('');
229
+
230
+ // Settings
231
+ const aspectRatio = ref(1); // 1:1 default
232
+ const baseSize = 600; // Base resolution size
233
+ const waveType = ref('bars');
234
+ const waveColor = ref('#ffffff');
235
+ const waveY = ref(80); // Y position percentage
236
+
237
+ // Audio Context
238
+ let audioCtx = null;
239
+ let analyser = null;
240
+ let source = null;
241
+ let dataArray = null;
242
+ let audioBuffer = null;
243
+ let startTime = 0;
244
+ let animationId = null;
245
+
246
+ // Playback state
247
+ const currentTime = ref(0);
248
+ const duration = ref(0);
249
+
250
+ // Recording
251
+ let mediaRecorder = null;
252
+ let recordedChunks = [];
253
+ let dest = null; // Audio destination for recording
254
+
255
+ const canvasWidth = computed(() => {
256
+ return baseSize;
257
+ });
258
+
259
+ const canvasHeight = computed(() => {
260
+ return baseSize / aspectRatio.value;
261
+ });
262
+
263
+ // Init
264
+ onMounted(() => {
265
+ drawCanvas();
266
+ });
267
+
268
+ watch([aspectRatio, waveType, waveColor, waveY, bgImageSrc], () => {
269
+ if (!isPlaying.value) drawCanvas();
270
+ });
271
+
272
+ function setAspectRatio(w, h) {
273
+ aspectRatio.value = w / h;
274
+ // Need next tick to wait for canvas resize
275
+ setTimeout(() => {
276
+ if (!isPlaying.value) drawCanvas();
277
+ }, 50);
278
+ }
279
+
280
+ function handleAudioUpload(event) {
281
+ const file = event.target.files[0];
282
+ if (!file) return;
283
+ audioFile.value = file;
284
+
285
+ // Create Audio Context if not exists
286
+ if (!audioCtx) {
287
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
288
+ }
289
+
290
+ // Decode audio data
291
+ const reader = new FileReader();
292
+ reader.onload = function(e) {
293
+ audioCtx.decodeAudioData(e.target.result, function(buffer) {
294
+ audioBuffer = buffer;
295
+ duration.value = buffer.duration;
296
+ drawCanvas(); // Redraw to clear previous state
297
+ });
298
+ };
299
+ reader.readAsArrayBuffer(file);
300
+ }
301
+
302
+ function handleImageUpload(event) {
303
+ const file = event.target.files[0];
304
+ if (!file) return;
305
+
306
+ const reader = new FileReader();
307
+ reader.onload = function(e) {
308
+ bgImageSrc.value = e.target.result;
309
+ };
310
+ reader.readAsDataURL(file);
311
+ }
312
+
313
+ function formatTime(seconds) {
314
+ const m = Math.floor(seconds / 60);
315
+ const s = Math.floor(seconds % 60);
316
+ return `${m}:${s.toString().padStart(2, '0')}`;
317
+ }
318
+
319
+ function togglePlay() {
320
+ if (isPlaying.value) {
321
+ stopPlayback();
322
+ } else {
323
+ startPlayback();
324
+ }
325
+ }
326
+
327
+ function startPlayback(isRecordingMode = false) {
328
+ if (!audioBuffer) return;
329
+
330
+ // Resume context if suspended
331
+ if (audioCtx.state === 'suspended') {
332
+ audioCtx.resume();
333
+ }
334
+
335
+ source = audioCtx.createBufferSource();
336
+ source.buffer = audioBuffer;
337
+
338
+ analyser = audioCtx.createAnalyser();
339
+ analyser.fftSize = 256;
340
+
341
+ // Connect graph
342
+ source.connect(analyser);
343
+
344
+ if (isRecordingMode) {
345
+ // Create destination for recording
346
+ dest = audioCtx.createMediaStreamDestination();
347
+ source.connect(dest);
348
+ // Also connect to speakers so user can hear (optional, maybe mute during record to avoid feedback?)
349
+ // Let's keep it audible
350
+ source.connect(audioCtx.destination);
351
+ } else {
352
+ source.connect(audioCtx.destination);
353
+ }
354
+
355
+ dataArray = new Uint8Array(analyser.frequencyBinCount);
356
+
357
+ source.start(0);
358
+ startTime = audioCtx.currentTime;
359
+ isPlaying.value = true;
360
+
361
+ source.onended = () => {
362
+ isPlaying.value = false;
363
+ if (isRecordingMode) {
364
+ stopRecording();
365
+ } else {
366
+ cancelAnimationFrame(animationId);
367
+ currentTime.value = 0;
368
+ drawCanvas(); // Reset view
369
+ }
370
+ };
371
+
372
+ animate();
373
+ }
374
+
375
+ function stopPlayback() {
376
+ if (source) {
377
+ source.stop();
378
+ source = null;
379
+ }
380
+ isPlaying.value = false;
381
+ cancelAnimationFrame(animationId);
382
+ }
383
+
384
+ function animate() {
385
+ if (!isPlaying.value) return;
386
+
387
+ animationId = requestAnimationFrame(animate);
388
+
389
+ // Update current time
390
+ currentTime.value = audioCtx.currentTime - startTime;
391
+ if (currentTime.value > duration.value) currentTime.value = duration.value;
392
+
393
+ // Get audio data
394
+ if (analyser) {
395
+ analyser.getByteFrequencyData(dataArray);
396
+ }
397
+
398
+ drawCanvas(dataArray);
399
+ }
400
+
401
+ function drawCanvas(audioData = null) {
402
+ const ctx = canvas.value.getContext('2d');
403
+ const w = canvas.value.width;
404
+ const h = canvas.value.height;
405
+
406
+ // 1. Background
407
+ ctx.fillStyle = '#111';
408
+ ctx.fillRect(0, 0, w, h);
409
+
410
+ if (bgImageSrc.value) {
411
+ const img = new Image();
412
+ img.src = bgImageSrc.value;
413
+ // Draw image cover
414
+ // Simplified sync draw (in reality should wait for load, but since dataURL is local it's fast)
415
+ // If it flickers, we can cache the image object
416
+
417
+ // Scale image to cover
418
+ const scale = Math.max(w / img.width, h / img.height);
419
+ const x = (w / 2) - (img.width / 2) * scale;
420
+ const y = (h / 2) - (img.height / 2) * scale;
421
+ ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
422
+ } else {
423
+ // Default Gradient
424
+ const grd = ctx.createLinearGradient(0, 0, w, h);
425
+ grd.addColorStop(0, '#4f46e5');
426
+ grd.addColorStop(1, '#9333ea');
427
+ ctx.fillStyle = grd;
428
+ ctx.fillRect(0, 0, w, h);
429
+ }
430
+
431
+ // 2. Waveform
432
+ if (audioData) {
433
+ drawWaveform(ctx, w, h, audioData);
434
+ } else {
435
+ // Draw static placeholder line
436
+ ctx.beginPath();
437
+ ctx.strokeStyle = waveColor.value;
438
+ ctx.globalAlpha = 0.5;
439
+ const y = h * (waveY.value / 100);
440
+ ctx.moveTo(0, y);
441
+ ctx.lineTo(w, y);
442
+ ctx.stroke();
443
+ ctx.globalAlpha = 1.0;
444
+ }
445
+ }
446
+
447
+ function drawWaveform(ctx, w, h, data) {
448
+ const centerY = h * (waveY.value / 100);
449
+ ctx.fillStyle = waveColor.value;
450
+ ctx.strokeStyle = waveColor.value;
451
+
452
+ const bufferLength = data.length;
453
+ // We only use the lower half of frequencies usually
454
+ const usableLength = Math.floor(bufferLength * 0.7);
455
+
456
+ if (waveType.value === 'bars') {
457
+ const barWidth = (w / usableLength) * 2.5;
458
+ let x = 0;
459
+ for(let i = 0; i < usableLength; i++) {
460
+ const v = data[i] / 255.0;
461
+ const barHeight = v * h * 0.4; // Max height 40% of canvas
462
+
463
+ ctx.fillRect(x, centerY - barHeight/2, barWidth, barHeight);
464
+ x += barWidth + 1;
465
+ }
466
+ } else if (waveType.value === 'line') {
467
+ ctx.lineWidth = 3;
468
+ ctx.beginPath();
469
+ const sliceWidth = w * 1.0 / usableLength;
470
+ let x = 0;
471
+ for(let i = 0; i < usableLength; i++) {
472
+ const v = data[i] / 128.0;
473
+ const y = v * (h/4) + centerY - (h/4); // Centered around centerY
474
+
475
+ if(i === 0) ctx.moveTo(x, y);
476
+ else ctx.lineTo(x, y);
477
+ x += sliceWidth;
478
+ }
479
+ ctx.stroke();
480
+ } else if (waveType.value === 'circle') {
481
+ // Circle visualization
482
+ const radius = Math.min(w, h) * 0.25;
483
+ const centerX = w / 2;
484
+ // For circle, we want to center it regardless of Y setting maybe?
485
+ // Or use Y setting as center Y? Let's use Y setting.
486
+
487
+ ctx.beginPath();
488
+ for(let i = 0; i < usableLength; i++) {
489
+ const v = data[i] / 255.0;
490
+ const barHeight = v * (radius * 0.8);
491
+ const angle = (i / usableLength) * Math.PI * 2;
492
+
493
+ const x1 = centerX + Math.cos(angle) * radius;
494
+ const y1 = centerY + Math.sin(angle) * radius;
495
+ const x2 = centerX + Math.cos(angle) * (radius + barHeight);
496
+ const y2 = centerY + Math.sin(angle) * (radius + barHeight);
497
+
498
+ ctx.moveTo(x1, y1);
499
+ ctx.lineTo(x2, y2);
500
+ }
501
+ ctx.stroke();
502
+ }
503
+ }
504
+
505
+ async function startRecording() {
506
+ if (!audioBuffer) return;
507
+ if (isPlaying.value) stopPlayback();
508
+
509
+ isRecording.value = true;
510
+ recordedChunks = [];
511
+
512
+ // Setup MediaRecorder
513
+ const canvasStream = canvas.value.captureStream(30); // 30 FPS
514
+
515
+ // Start Playback (which creates 'dest' audio destination)
516
+ startPlayback(true);
517
+
518
+ // Combine streams
519
+ // dest is created in startPlayback
520
+ const combinedStream = new MediaStream([
521
+ ...canvasStream.getVideoTracks(),
522
+ ...dest.stream.getAudioTracks()
523
+ ]);
524
+
525
+ try {
526
+ // Try vp9, fallback to vp8
527
+ const options = { mimeType: 'video/webm;codecs=vp9' };
528
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
529
+ options.mimeType = 'video/webm;codecs=vp8';
530
+ }
531
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
532
+ options.mimeType = 'video/webm';
533
+ }
534
+
535
+ mediaRecorder = new MediaRecorder(combinedStream, options);
536
+
537
+ mediaRecorder.ondataavailable = (event) => {
538
+ if (event.data.size > 0) {
539
+ recordedChunks.push(event.data);
540
+ }
541
+ };
542
+
543
+ mediaRecorder.onstop = () => {
544
+ const blob = new Blob(recordedChunks, { type: 'video/webm' });
545
+ videoUrl.value = URL.createObjectURL(blob);
546
+ showResultModal.value = true;
547
+ isRecording.value = false;
548
+ };
549
+
550
+ mediaRecorder.start();
551
+
552
+ } catch (e) {
553
+ console.error("Recording error:", e);
554
+ alert("录制初始化失败,您的浏览器可能不支持 MediaRecorder。");
555
+ isRecording.value = false;
556
+ stopPlayback();
557
+ }
558
+ }
559
+
560
+ function stopRecording() {
561
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
562
+ mediaRecorder.stop();
563
+ }
564
+ // Playback stops automatically in source.onended
565
+ }
566
+
567
+ return {
568
+ canvas,
569
+ audioFile,
570
+ bgImageSrc,
571
+ isPlaying,
572
+ isRecording,
573
+ aspectRatio,
574
+ waveType,
575
+ waveColor,
576
+ waveY,
577
+ canvasWidth,
578
+ canvasHeight,
579
+ currentTime,
580
+ duration,
581
+ showResultModal,
582
+ videoUrl,
583
+ handleAudioUpload,
584
+ handleImageUpload,
585
+ setAspectRatio,
586
+ togglePlay,
587
+ startRecording,
588
+ formatTime
589
+ };
590
+ }
591
+ }).mount('#app');
592
+ </script>
593
+ </body>
594
+ </html>