Spaces:
Sleeping
Sleeping
Commit ·
c4aaa51
1
Parent(s): ebb2801
Add MP4 conversion feature
Browse files- app.py +36 -3
- templates/index.html +43 -10
app.py
CHANGED
|
@@ -14,9 +14,42 @@ os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
|
|
| 14 |
def index():
|
| 15 |
return render_template('index.html')
|
| 16 |
|
| 17 |
-
@app.route('/
|
| 18 |
-
def
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
if __name__ == '__main__':
|
| 22 |
app.run(host='0.0.0.0', port=7860)
|
|
|
|
| 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 |
+
|
| 22 |
+
video = request.files['video']
|
| 23 |
+
filename = str(uuid.uuid4())
|
| 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):
|
| 51 |
+
return send_file(os.path.join(app.config['OUTPUT_FOLDER'], filename), as_attachment=True)
|
| 52 |
+
|
| 53 |
|
| 54 |
if __name__ == '__main__':
|
| 55 |
app.run(host='0.0.0.0', port=7860)
|
templates/index.html
CHANGED
|
@@ -159,10 +159,10 @@
|
|
| 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">
|
|
@@ -200,12 +200,13 @@
|
|
| 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">您的视频已生成 (
|
| 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.
|
| 209 |
<i class="fa-solid fa-download mr-2"></i> 下载视频
|
| 210 |
</a>
|
| 211 |
</div>
|
|
@@ -224,8 +225,10 @@
|
|
| 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
|
|
@@ -540,11 +543,39 @@
|
|
| 540 |
}
|
| 541 |
};
|
| 542 |
|
| 543 |
-
mediaRecorder.onstop = () => {
|
| 544 |
const blob = new Blob(recordedChunks, { type: 'video/webm' });
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
};
|
| 549 |
|
| 550 |
mediaRecorder.start();
|
|
@@ -580,6 +611,8 @@
|
|
| 580 |
duration,
|
| 581 |
showResultModal,
|
| 582 |
videoUrl,
|
|
|
|
|
|
|
| 583 |
handleAudioUpload,
|
| 584 |
handleImageUpload,
|
| 585 |
setAspectRatio,
|
|
|
|
| 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>
|
| 166 |
</button>
|
| 167 |
|
| 168 |
<div v-if="isRecording" class="text-center text-sm text-gray-500 mt-2">
|
|
|
|
| 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">您的视频已生成 ({{ videoType.toUpperCase() }}格式)</p>
|
| 204 |
+
<p class="text-xs text-gray-500" v-if="videoType === 'webm'">注意:WebM 格式在 Windows/Android 上兼容性良好。如需 MP4,请使用在线工具转换。</p>
|
| 205 |
+
<p class="text-xs text-gray-500" v-else>MP4 格式,兼容所有平台 (iOS/Android/PC)。</p>
|
| 206 |
</div>
|
| 207 |
|
| 208 |
<div class="flex space-x-3">
|
| 209 |
+
<a :href="videoUrl" :download="'audiogram.' + videoType" class="flex-1 bg-indigo-600 text-white py-3 rounded-lg font-semibold text-center hover:bg-indigo-700 transition-colors">
|
| 210 |
<i class="fa-solid fa-download mr-2"></i> 下载视频
|
| 211 |
</a>
|
| 212 |
</div>
|
|
|
|
| 225 |
const bgImageSrc = ref(null);
|
| 226 |
const isPlaying = ref(false);
|
| 227 |
const isRecording = ref(false);
|
| 228 |
+
const isConverting = ref(false);
|
| 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
|
|
|
|
| 543 |
}
|
| 544 |
};
|
| 545 |
|
| 546 |
+
mediaRecorder.onstop = async () => {
|
| 547 |
const blob = new Blob(recordedChunks, { type: 'video/webm' });
|
| 548 |
+
// Upload for conversion
|
| 549 |
+
isConverting.value = true;
|
| 550 |
+
|
| 551 |
+
const formData = new FormData();
|
| 552 |
+
formData.append('video', blob, 'recording.webm');
|
| 553 |
+
|
| 554 |
+
try {
|
| 555 |
+
const response = await fetch('/convert', {
|
| 556 |
+
method: 'POST',
|
| 557 |
+
body: formData
|
| 558 |
+
});
|
| 559 |
+
|
| 560 |
+
if (response.ok) {
|
| 561 |
+
const data = await response.json();
|
| 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);
|
| 572 |
+
videoUrl.value = URL.createObjectURL(blob);
|
| 573 |
+
videoType.value = 'webm';
|
| 574 |
+
} finally {
|
| 575 |
+
isConverting.value = false;
|
| 576 |
+
showResultModal.value = true;
|
| 577 |
+
isRecording.value = false;
|
| 578 |
+
}
|
| 579 |
};
|
| 580 |
|
| 581 |
mediaRecorder.start();
|
|
|
|
| 611 |
duration,
|
| 612 |
showResultModal,
|
| 613 |
videoUrl,
|
| 614 |
+
isConverting,
|
| 615 |
+
videoType,
|
| 616 |
handleAudioUpload,
|
| 617 |
handleImageUpload,
|
| 618 |
setAspectRatio,
|