duqing2026 commited on
Commit
0616ade
·
1 Parent(s): c4aaa51

升级优化

Browse files
Files changed (2) hide show
  1. app.py +92 -14
  2. templates/index.html +237 -16
app.py CHANGED
@@ -1,4 +1,5 @@
1
  from flask import Flask, render_template, request, jsonify, send_file
 
2
  import os
3
  import subprocess
4
  import uuid
@@ -6,16 +7,77 @@ import uuid
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('/convert', methods=['POST'])
18
  def convert():
 
 
19
  if 'video' not in request.files:
20
  return jsonify({"error": "No video file"}), 400
21
 
@@ -24,27 +86,43 @@ def convert():
24
  input_path = os.path.join(app.config['UPLOAD_FOLDER'], filename + '.webm')
25
  output_path = os.path.join(app.config['OUTPUT_FOLDER'], filename + '.mp4')
26
 
27
- video.save(input_path)
28
-
29
- # FFmpeg conversion
30
- # -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2" ensures dimensions are even for libx264
31
- cmd = [
32
- 'ffmpeg', '-y', '-i', input_path,
33
- '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
34
- '-c:a', 'aac', '-b:a', '192k',
35
- '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
36
- output_path
37
- ]
38
-
39
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
41
  return jsonify({"url": f"/download/{filename}.mp4"})
 
42
  except subprocess.CalledProcessError as e:
 
43
  return jsonify({"error": "Conversion failed", "details": str(e)}), 500
 
 
 
44
  finally:
45
  # Cleanup input
46
  if os.path.exists(input_path):
47
- os.remove(input_path)
 
 
 
48
 
49
  @app.route('/download/<filename>')
50
  def download_file(filename):
 
1
  from flask import Flask, render_template, request, jsonify, send_file
2
+ from werkzeug.exceptions import HTTPException
3
  import os
4
  import subprocess
5
  import uuid
 
7
  app = Flask(__name__)
8
  app.config['UPLOAD_FOLDER'] = 'uploads'
9
  app.config['OUTPUT_FOLDER'] = 'outputs'
10
+ app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB limit to prevent server overload
11
 
12
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
13
  os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
14
 
15
+ import shutil
16
+ import time
17
+ import traceback
18
+
19
+ # Check if ffmpeg is installed
20
+ FFMPEG_AVAILABLE = shutil.which('ffmpeg') is not None
21
+
22
+ def cleanup_old_files():
23
+ """Cleanup files older than 1 hour"""
24
+ try:
25
+ now = time.time()
26
+ for folder in [app.config['UPLOAD_FOLDER'], app.config['OUTPUT_FOLDER']]:
27
+ if not os.path.exists(folder):
28
+ continue
29
+ for filename in os.listdir(folder):
30
+ filepath = os.path.join(folder, filename)
31
+ # Remove if older than 1 hour
32
+ if os.path.isfile(filepath) and os.path.getmtime(filepath) < now - 3600:
33
+ try:
34
+ os.remove(filepath)
35
+ except Exception:
36
+ pass
37
+ except Exception:
38
+ pass
39
+
40
+ @app.errorhandler(500)
41
+ def internal_error(error):
42
+ return jsonify({
43
+ "error": "Internal Server Error",
44
+ "details": str(error),
45
+ "trace": traceback.format_exc(),
46
+ "suggestion": "Please try again with a smaller file or different format."
47
+ }), 500
48
+
49
+ @app.errorhandler(413)
50
+ def request_entity_too_large(error):
51
+ return jsonify({
52
+ "error": "File Too Large",
53
+ "details": "The uploaded file exceeds the 100MB limit."
54
+ }), 413
55
+
56
+ @app.errorhandler(Exception)
57
+ def handle_exception(e):
58
+ # Pass through HTTP errors
59
+ if isinstance(e, HTTPException):
60
+ return e
61
+
62
+ # Log the full traceback
63
+ print("Error occurred:")
64
+ traceback.print_exc()
65
+
66
+ # Now you're handling non-HTTP exceptions only
67
+ return jsonify({
68
+ "error": "Unexpected Error",
69
+ "details": str(e),
70
+ "trace": traceback.format_exc() # Return trace to client for debugging
71
+ }), 500
72
+
73
  @app.route('/')
74
  def index():
75
+ return render_template('index.html', ffmpeg_available=FFMPEG_AVAILABLE)
76
 
77
  @app.route('/convert', methods=['POST'])
78
  def convert():
79
+ cleanup_old_files()
80
+
81
  if 'video' not in request.files:
82
  return jsonify({"error": "No video file"}), 400
83
 
 
86
  input_path = os.path.join(app.config['UPLOAD_FOLDER'], filename + '.webm')
87
  output_path = os.path.join(app.config['OUTPUT_FOLDER'], filename + '.mp4')
88
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  try:
90
+ video.save(input_path)
91
+
92
+ if not FFMPEG_AVAILABLE:
93
+ # If ffmpeg is not available, return the webm file directly or error
94
+ # For better UX, we could just return the webm url if we saved it to outputs
95
+ # But the frontend expects a conversion. Let's return a specific error.
96
+ return jsonify({"error": "FFmpeg not installed on server", "fallback": True}), 503
97
+
98
+ # FFmpeg conversion
99
+ # -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2" ensures dimensions are even for libx264
100
+ # -pix_fmt yuv420p is often required for broad compatibility
101
+ cmd = [
102
+ 'ffmpeg', '-y', '-i', input_path,
103
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
104
+ '-pix_fmt', 'yuv420p',
105
+ '-c:a', 'aac', '-b:a', '192k',
106
+ '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
107
+ output_path
108
+ ]
109
+
110
  subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
111
  return jsonify({"url": f"/download/{filename}.mp4"})
112
+
113
  except subprocess.CalledProcessError as e:
114
+ print(f"FFmpeg Error: {e.stderr.decode() if e.stderr else str(e)}")
115
  return jsonify({"error": "Conversion failed", "details": str(e)}), 500
116
+ except Exception as e:
117
+ print(f"General Error: {str(e)}")
118
+ return jsonify({"error": "Internal server error", "details": str(e)}), 500
119
  finally:
120
  # Cleanup input
121
  if os.path.exists(input_path):
122
+ try:
123
+ os.remove(input_path)
124
+ except:
125
+ pass
126
 
127
  @app.route('/download/<filename>')
128
  def download_file(filename):
templates/index.html CHANGED
@@ -33,6 +33,13 @@
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">
@@ -41,13 +48,18 @@
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
 
@@ -72,6 +84,9 @@
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
  点击或拖拽上传音频
@@ -86,8 +101,11 @@
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>
@@ -96,6 +114,31 @@
96
  </div>
97
  </div>
98
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  </div>
100
 
101
  <!-- 2. Settings -->
@@ -104,6 +147,18 @@
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>
@@ -127,9 +182,24 @@
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>
@@ -137,6 +207,16 @@
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">
@@ -152,14 +232,14 @@
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 || isConverting"
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' : (isConverting ? 'fa-cog fa-spin' : 'fa-video')"></i>
165
  <span>{{ isRecording ? '正在录制...' : (isConverting ? '正在转码为 MP4...' : '开始生成视频') }}</span>
@@ -214,6 +294,7 @@
214
  </div>
215
 
216
  </div>
 
217
 
218
  <script>
219
  const { createApp, ref, onMounted, watch, computed } = Vue;
@@ -222,6 +303,7 @@
222
  setup() {
223
  const canvas = ref(null);
224
  const audioFile = ref(null);
 
225
  const bgImageSrc = ref(null);
226
  const isPlaying = ref(false);
227
  const isRecording = ref(false);
@@ -229,6 +311,7 @@
229
  const showResultModal = ref(false);
230
  const videoUrl = ref('');
231
  const videoType = ref('webm');
 
232
 
233
  // Settings
234
  const aspectRatio = ref(1); // 1:1 default
@@ -236,6 +319,12 @@
236
  const waveType = ref('bars');
237
  const waveColor = ref('#ffffff');
238
  const waveY = ref(80); // Y position percentage
 
 
 
 
 
 
239
 
240
  // Audio Context
241
  let audioCtx = null;
@@ -266,9 +355,10 @@
266
  // Init
267
  onMounted(() => {
268
  drawCanvas();
 
269
  });
270
 
271
- watch([aspectRatio, waveType, waveColor, waveY, bgImageSrc], () => {
272
  if (!isPlaying.value) drawCanvas();
273
  });
274
 
@@ -279,16 +369,63 @@
279
  if (!isPlaying.value) drawCanvas();
280
  }, 50);
281
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  function handleAudioUpload(event) {
284
  const file = event.target.files[0];
285
  if (!file) return;
286
  audioFile.value = file;
287
 
288
- // Create Audio Context if not exists
289
- if (!audioCtx) {
290
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
291
- }
292
 
293
  // Decode audio data
294
  const reader = new FileReader();
@@ -296,6 +433,7 @@
296
  audioCtx.decodeAudioData(e.target.result, function(buffer) {
297
  audioBuffer = buffer;
298
  duration.value = buffer.duration;
 
299
  drawCanvas(); // Redraw to clear previous state
300
  });
301
  };
@@ -313,6 +451,21 @@
313
  reader.readAsDataURL(file);
314
  }
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  function formatTime(seconds) {
317
  const m = Math.floor(seconds / 60);
318
  const s = Math.floor(seconds % 60);
@@ -445,6 +598,38 @@
445
  ctx.stroke();
446
  ctx.globalAlpha = 1.0;
447
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  }
449
 
450
  function drawWaveform(ctx, w, h, data) {
@@ -457,14 +642,14 @@
457
  const usableLength = Math.floor(bufferLength * 0.7);
458
 
459
  if (waveType.value === 'bars') {
460
- const barWidth = (w / usableLength) * 2.5;
461
  let x = 0;
462
  for(let i = 0; i < usableLength; i++) {
463
  const v = data[i] / 255.0;
464
  const barHeight = v * h * 0.4; // Max height 40% of canvas
465
 
466
  ctx.fillRect(x, centerY - barHeight/2, barWidth, barHeight);
467
- x += barWidth + 1;
468
  }
469
  } else if (waveType.value === 'line') {
470
  ctx.lineWidth = 3;
@@ -502,6 +687,26 @@
502
  ctx.lineTo(x2, y2);
503
  }
504
  ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  }
506
  }
507
 
@@ -562,10 +767,20 @@
562
  videoUrl.value = data.url;
563
  videoType.value = 'mp4';
564
  } else {
565
- // Fallback to webm if backend fails
566
- console.error("Conversion failed, falling back to WebM");
 
 
567
  videoUrl.value = URL.createObjectURL(blob);
568
  videoType.value = 'webm';
 
 
 
 
 
 
 
 
569
  }
570
  } catch (e) {
571
  console.error("Network error, falling back to WebM", e);
@@ -613,8 +828,14 @@
613
  videoUrl,
614
  isConverting,
615
  videoType,
 
 
 
 
616
  handleAudioUpload,
617
  handleImageUpload,
 
 
618
  setAspectRatio,
619
  togglePlay,
620
  startRecording,
 
33
  </style>
34
  </head>
35
  <body>
36
+ <script>
37
+ // Pass server-side variables to client
38
+ window.SERVER_CONFIG = {
39
+ ffmpegAvailable: {{ 'true' if ffmpeg_available else 'false' }}
40
+ };
41
+ </script>
42
+ {% raw %}
43
  <div id="app" class="min-h-screen flex flex-col">
44
  <!-- Header -->
45
  <header class="bg-white shadow-sm z-10">
 
48
  <div class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white p-2 rounded-lg">
49
  <i class="fa-solid fa-wave-square text-xl"></i>
50
  </div>
51
+ <div>
52
+ <h1 class="text-2xl font-bold text-gray-900 tracking-tight">Audiogram Studio</h1>
53
+ <div v-if="!serverConfig.ffmpegAvailable" class="text-xs text-orange-500 font-medium">
54
+ <i class="fa-solid fa-circle-info mr-1"></i>WebM 模式 (无 FFmpeg)
55
+ </div>
56
+ </div>
57
  </div>
58
+ <!-- <div class="flex items-center space-x-4">
59
  <a href="https://github.com/duqing26" target="_blank" class="text-gray-500 hover:text-gray-900 transition-colors">
60
  <i class="fa-brands fa-github text-xl"></i>
61
  </a>
62
+ </div> -->
63
  </div>
64
  </header>
65
 
 
84
  <div v-if="audioFile" class="text-indigo-600 font-medium truncate">
85
  <i class="fa-solid fa-music mr-2"></i> {{ audioFile.name }}
86
  </div>
87
+ <div v-else-if="hasAudio" class="text-green-600 font-medium truncate">
88
+ <i class="fa-solid fa-music mr-2"></i> 示例音频 (10s)
89
+ </div>
90
  <div v-else class="text-gray-500">
91
  <i class="fa-solid fa-file-audio text-2xl mb-2 block text-gray-400"></i>
92
  点击或拖拽上传音频
 
101
  <div class="relative group">
102
  <input type="file" accept="image/*" @change="handleImageUpload" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
103
  <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">
104
+ <div v-if="bgImageSrc && !bgImageSrc.startsWith('data:image')" class="text-indigo-600 font-medium truncate">
105
+ <i class="fa-solid fa-image mr-2"></i> 已加载自定义图片
106
+ </div>
107
+ <div v-else-if="bgImageSrc" class="text-green-600 font-medium truncate">
108
+ <i class="fa-solid fa-image mr-2"></i> 已加载默认背景
109
  </div>
110
  <div v-else class="text-gray-500">
111
  <i class="fa-regular fa-image text-2xl mb-2 block text-gray-400"></i>
 
114
  </div>
115
  </div>
116
  </div>
117
+ <!-- Watermark Upload -->
118
+ <div class="mb-4">
119
+ <label class="block text-sm font-medium text-gray-700 mb-2">水印/Logo (可选)</label>
120
+ <div class="relative group">
121
+ <input type="file" accept="image/*" @change="handleWatermarkUpload" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
122
+ <div class="border border-dashed border-gray-300 rounded-lg p-2 text-center group-hover:border-indigo-500 transition-colors bg-gray-50 text-sm">
123
+ <div v-if="watermarkSrc" class="text-indigo-600 truncate">
124
+ <i class="fa-solid fa-stamp mr-1"></i> 已加载 Logo
125
+ </div>
126
+ <div v-else class="text-gray-500">
127
+ <i class="fa-regular fa-id-badge mb-1 block"></i>
128
+ 点击上传 Logo
129
+ </div>
130
+ </div>
131
+ </div>
132
+ <div v-if="watermarkSrc" class="mt-2 flex items-center justify-between">
133
+ <label class="text-xs text-gray-500">位置</label>
134
+ <select v-model="watermarkPos" class="text-xs border-gray-300 rounded p-1">
135
+ <option value="top-left">左上</option>
136
+ <option value="top-right">右上</option>
137
+ <option value="bottom-left">左下</option>
138
+ <option value="bottom-right">右下</option>
139
+ </select>
140
+ </div>
141
+ </div>
142
  </div>
143
 
144
  <!-- 2. Settings -->
 
147
  <i class="fa-solid fa-sliders mr-2 text-indigo-500"></i> 样式设置
148
  </h2>
149
 
150
+ <!-- Presets -->
151
+ <div class="mb-4">
152
+ <label class="block text-sm font-medium text-gray-700 mb-2">快速配色</label>
153
+ <div class="flex space-x-2">
154
+ <button @click="applyPalette('#ffffff')" class="w-6 h-6 rounded-full bg-white border border-gray-300 shadow-sm" title="White"></button>
155
+ <button @click="applyPalette('#fbbf24')" class="w-6 h-6 rounded-full bg-yellow-400 shadow-sm" title="Amber"></button>
156
+ <button @click="applyPalette('#f472b6')" class="w-6 h-6 rounded-full bg-pink-400 shadow-sm" title="Pink"></button>
157
+ <button @click="applyPalette('#60a5fa')" class="w-6 h-6 rounded-full bg-blue-400 shadow-sm" title="Blue"></button>
158
+ <button @click="applyPalette('#34d399')" class="w-6 h-6 rounded-full bg-emerald-400 shadow-sm" title="Green"></button>
159
+ </div>
160
+ </div>
161
+
162
  <!-- Canvas Size -->
163
  <div class="mb-4">
164
  <label class="block text-sm font-medium text-gray-700 mb-2">画布比例</label>
 
182
  <option value="bars">柱状图 (Bars)</option>
183
  <option value="line">线条 (Line)</option>
184
  <option value="circle">圆形 (Circle)</option>
185
+ <option value="particles">粒子 (Particles)</option>
186
  </select>
187
  </div>
188
 
189
+ <!-- Bar Settings -->
190
+ <div class="mb-4" v-if="waveType === 'bars'">
191
+ <div class="grid grid-cols-2 gap-4">
192
+ <div>
193
+ <label class="block text-xs text-gray-500 mb-1">宽度系数 ({{ barScale }})</label>
194
+ <input type="range" v-model.number="barScale" min="0.1" max="10" step="0.1" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
195
+ </div>
196
+ <div>
197
+ <label class="block text-xs text-gray-500 mb-1">间距 ({{ barGap }}px)</label>
198
+ <input type="range" v-model.number="barGap" min="0" max="20" step="1" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
199
+ </div>
200
+ </div>
201
+ </div>
202
+
203
  <!-- Color Picker -->
204
  <div class="mb-4">
205
  <label class="block text-sm font-medium text-gray-700 mb-2">波形颜色</label>
 
207
  <input type="color" v-model="waveColor" class="h-10 w-full rounded cursor-pointer border-0 p-0">
208
  </div>
209
  </div>
210
+
211
+ <!-- Text Overlay -->
212
+ <div class="mb-4">
213
+ <label class="block text-sm font-medium text-gray-700 mb-2">标题文字</label>
214
+ <input type="text" v-model="titleText" placeholder="输入标题..." 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 mb-2">
215
+ <div v-if="titleText">
216
+ <label class="block text-xs text-gray-500 mb-1">字体大小 ({{ titleFontSize }}px)</label>
217
+ <input type="range" v-model.number="titleFontSize" min="20" max="120" step="1" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
218
+ </div>
219
+ </div>
220
 
221
  <!-- Position -->
222
  <div class="mb-4">
 
232
  </h2>
233
 
234
  <div class="space-y-3">
235
+ <button @click="togglePlay" :disabled="!hasAudio"
236
  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"
237
  :class="isPlaying ? 'bg-yellow-500 hover:bg-yellow-600 text-white' : 'bg-green-500 hover:bg-green-600 text-white'">
238
  <i class="fa-solid" :class="isPlaying ? 'fa-pause' : 'fa-play'"></i>
239
  <span>{{ isPlaying ? '暂停预览' : '播放预览' }}</span>
240
  </button>
241
 
242
+ <button @click="startRecording" :disabled="!hasAudio || isRecording || isConverting"
243
  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">
244
  <i class="fa-solid" :class="isRecording ? 'fa-spinner fa-spin' : (isConverting ? 'fa-cog fa-spin' : 'fa-video')"></i>
245
  <span>{{ isRecording ? '正在录制...' : (isConverting ? '正在转码为 MP4...' : '开始生成视频') }}</span>
 
294
  </div>
295
 
296
  </div>
297
+ {% endraw %}
298
 
299
  <script>
300
  const { createApp, ref, onMounted, watch, computed } = Vue;
 
303
  setup() {
304
  const canvas = ref(null);
305
  const audioFile = ref(null);
306
+ const hasAudio = ref(false); // Track if audio is loaded (file or demo)
307
  const bgImageSrc = ref(null);
308
  const isPlaying = ref(false);
309
  const isRecording = ref(false);
 
311
  const showResultModal = ref(false);
312
  const videoUrl = ref('');
313
  const videoType = ref('webm');
314
+ const serverConfig = ref(window.SERVER_CONFIG || { ffmpegAvailable: false });
315
 
316
  // Settings
317
  const aspectRatio = ref(1); // 1:1 default
 
319
  const waveType = ref('bars');
320
  const waveColor = ref('#ffffff');
321
  const waveY = ref(80); // Y position percentage
322
+ const barScale = ref(2.5);
323
+ const barGap = ref(1);
324
+ const titleText = ref('');
325
+ const titleFontSize = ref(40);
326
+ const watermarkSrc = ref(null);
327
+ const watermarkPos = ref('top-right');
328
 
329
  // Audio Context
330
  let audioCtx = null;
 
355
  // Init
356
  onMounted(() => {
357
  drawCanvas();
358
+ loadDemo();
359
  });
360
 
361
+ watch([aspectRatio, waveType, waveColor, waveY, barScale, barGap, bgImageSrc, titleText, titleFontSize, watermarkSrc, watermarkPos], () => {
362
  if (!isPlaying.value) drawCanvas();
363
  });
364
 
 
369
  if (!isPlaying.value) drawCanvas();
370
  }, 50);
371
  }
372
+
373
+ function initAudioCtx() {
374
+ if (!audioCtx) {
375
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
376
+ }
377
+ }
378
+
379
+ function loadDemo() {
380
+ initAudioCtx();
381
+
382
+ // Generate a demo sine wave sweep
383
+ const sampleRate = audioCtx.sampleRate;
384
+ const demoDuration = 10; // 10 seconds
385
+ const frameCount = sampleRate * demoDuration;
386
+ const buffer = audioCtx.createBuffer(1, frameCount, sampleRate);
387
+ const data = buffer.getChannelData(0);
388
+
389
+ for (let i = 0; i < frameCount; i++) {
390
+ // A simple melody or sweep
391
+ // Sweep from 200Hz to 800Hz
392
+ const t = i / sampleRate;
393
+ const freq = 200 + (t / demoDuration) * 600;
394
+ data[i] = Math.sin(t * freq * Math.PI * 2) * 0.5 * (1 - t/demoDuration);
395
+ }
396
+
397
+ audioBuffer = buffer;
398
+ duration.value = demoDuration;
399
+ hasAudio.value = true;
400
+
401
+ // Demo Background - Create a gradient image
402
+ const tempCanvas = document.createElement('canvas');
403
+ tempCanvas.width = 600;
404
+ tempCanvas.height = 600;
405
+ const tCtx = tempCanvas.getContext('2d');
406
+ const grd = tCtx.createLinearGradient(0, 0, 600, 600);
407
+ grd.addColorStop(0, '#1e3a8a');
408
+ grd.addColorStop(1, '#db2777');
409
+ tCtx.fillStyle = grd;
410
+ tCtx.fillRect(0,0,600,600);
411
+
412
+ // Add some text
413
+ tCtx.fillStyle = 'rgba(255,255,255,0.2)';
414
+ tCtx.font = 'bold 80px sans-serif';
415
+ tCtx.fillText("DEMO", 180, 320);
416
+
417
+ bgImageSrc.value = tempCanvas.toDataURL();
418
+
419
+ // Update View
420
+ setTimeout(drawCanvas, 100);
421
+ }
422
 
423
  function handleAudioUpload(event) {
424
  const file = event.target.files[0];
425
  if (!file) return;
426
  audioFile.value = file;
427
 
428
+ initAudioCtx();
 
 
 
429
 
430
  // Decode audio data
431
  const reader = new FileReader();
 
433
  audioCtx.decodeAudioData(e.target.result, function(buffer) {
434
  audioBuffer = buffer;
435
  duration.value = buffer.duration;
436
+ hasAudio.value = true;
437
  drawCanvas(); // Redraw to clear previous state
438
  });
439
  };
 
451
  reader.readAsDataURL(file);
452
  }
453
 
454
+ function handleWatermarkUpload(event) {
455
+ const file = event.target.files[0];
456
+ if (!file) return;
457
+
458
+ const reader = new FileReader();
459
+ reader.onload = function(e) {
460
+ watermarkSrc.value = e.target.result;
461
+ };
462
+ reader.readAsDataURL(file);
463
+ }
464
+
465
+ function applyPalette(color) {
466
+ waveColor.value = color;
467
+ }
468
+
469
  function formatTime(seconds) {
470
  const m = Math.floor(seconds / 60);
471
  const s = Math.floor(seconds % 60);
 
598
  ctx.stroke();
599
  ctx.globalAlpha = 1.0;
600
  }
601
+
602
+ // 3. Title Text
603
+ if (titleText.value) {
604
+ ctx.fillStyle = '#ffffff';
605
+ ctx.font = 'bold ' + titleFontSize.value + 'px sans-serif';
606
+ ctx.textAlign = 'center';
607
+ ctx.shadowColor = 'rgba(0,0,0,0.5)';
608
+ ctx.shadowBlur = 10;
609
+ ctx.fillText(titleText.value, w / 2, 100);
610
+ ctx.shadowBlur = 0;
611
+ }
612
+
613
+ // 4. Watermark
614
+ if (watermarkSrc.value) {
615
+ const logo = new Image();
616
+ logo.src = watermarkSrc.value;
617
+ // Assuming loaded since local dataURL
618
+ const logoSize = Math.min(w, h) * 0.15; // 15% of min dimension
619
+ const padding = 20;
620
+
621
+ let lx = padding;
622
+ let ly = padding;
623
+
624
+ if (watermarkPos.value.includes('right')) {
625
+ lx = w - logoSize - padding;
626
+ }
627
+ if (watermarkPos.value.includes('bottom')) {
628
+ ly = h - logoSize - padding;
629
+ }
630
+
631
+ ctx.drawImage(logo, lx, ly, logoSize, logoSize);
632
+ }
633
  }
634
 
635
  function drawWaveform(ctx, w, h, data) {
 
642
  const usableLength = Math.floor(bufferLength * 0.7);
643
 
644
  if (waveType.value === 'bars') {
645
+ const barWidth = (w / usableLength) * barScale.value;
646
  let x = 0;
647
  for(let i = 0; i < usableLength; i++) {
648
  const v = data[i] / 255.0;
649
  const barHeight = v * h * 0.4; // Max height 40% of canvas
650
 
651
  ctx.fillRect(x, centerY - barHeight/2, barWidth, barHeight);
652
+ x += barWidth + barGap.value;
653
  }
654
  } else if (waveType.value === 'line') {
655
  ctx.lineWidth = 3;
 
687
  ctx.lineTo(x2, y2);
688
  }
689
  ctx.stroke();
690
+ } else if (waveType.value === 'particles') {
691
+ const centerX = w / 2;
692
+ const centerY = h * (waveY.value / 100);
693
+
694
+ for(let i = 0; i < usableLength; i += 2) {
695
+ const v = data[i];
696
+ if (v < 10) continue; // Skip silence
697
+
698
+ const angle = (i / usableLength) * Math.PI * 2;
699
+ const radius = v * (h * 0.005);
700
+ const distance = 50 + (v * 0.5);
701
+
702
+ const x = centerX + Math.cos(angle) * distance;
703
+ const y = centerY + Math.sin(angle) * distance;
704
+
705
+ ctx.beginPath();
706
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
707
+ ctx.fillStyle = waveColor.value;
708
+ ctx.fill();
709
+ }
710
  }
711
  }
712
 
 
767
  videoUrl.value = data.url;
768
  videoType.value = 'mp4';
769
  } else {
770
+ // Fallback logic
771
+ const errorData = await response.json().catch(() => ({}));
772
+ console.warn("Server returned error:", response.status, errorData);
773
+
774
  videoUrl.value = URL.createObjectURL(blob);
775
  videoType.value = 'webm';
776
+
777
+ if (response.status === 503 && errorData.fallback) {
778
+ // FFmpeg missing but handled gracefully
779
+ // No alert needed, just show result
780
+ } else {
781
+ // Real error
782
+ console.error("Conversion failed, using WebM fallback");
783
+ }
784
  }
785
  } catch (e) {
786
  console.error("Network error, falling back to WebM", e);
 
828
  videoUrl,
829
  isConverting,
830
  videoType,
831
+ serverConfig,
832
+ titleText,
833
+ watermarkSrc,
834
+ watermarkPos,
835
  handleAudioUpload,
836
  handleImageUpload,
837
+ handleWatermarkUpload,
838
+ applyPalette,
839
  setAspectRatio,
840
  togglePlay,
841
  startRecording,