Spaces:
Sleeping
Sleeping
Commit ·
9b1b216
1
Parent(s): f16a23e
优化升级
Browse files- README.md +2 -0
- app.py +4 -0
- templates/index.html +103 -6
README.md
CHANGED
|
@@ -16,6 +16,8 @@ short_description: '爆款视频封面大师'
|
|
| 16 |
## 功能特点
|
| 17 |
|
| 18 |
- **多尺寸支持**:16:9 (视频), 4:3, 1:1, 9:16 (Shorts/抖音)
|
|
|
|
|
|
|
| 19 |
- **爆款样式**:内置“震惊红”、“搞钱黄”、“科技蓝”等高点击配色
|
| 20 |
- **拖拽编辑**:所有元素(标题、副标题、贴纸、人物)均支持自由拖拽
|
| 21 |
- **隐私安全**:所有图片处理均在浏览器本地完成,不上传服务器
|
|
|
|
| 16 |
## 功能特点
|
| 17 |
|
| 18 |
- **多尺寸支持**:16:9 (视频), 4:3, 1:1, 9:16 (Shorts/抖音)
|
| 19 |
+
- **随机灵感**:一键生成不同风格的封面配置,激发创意
|
| 20 |
+
- **丰富字体**:集成多款免费商用中文字体(快乐体、马善政、站酷黄油等)
|
| 21 |
- **爆款样式**:内置“震惊红”、“搞钱黄”、“科技蓝”等高点击配色
|
| 22 |
- **拖拽编辑**:所有元素(标题、副标题、贴纸、人物)均支持自由拖拽
|
| 23 |
- **隐私安全**:所有图片处理均在浏览器本地完成,不上传服务器
|
app.py
CHANGED
|
@@ -11,6 +11,10 @@ app.jinja_env.variable_end_string = ']]'
|
|
| 11 |
def index():
|
| 12 |
return render_template('index.html')
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
@app.route('/static/<path:path>')
|
| 15 |
def send_static(path):
|
| 16 |
return send_from_directory('static', path)
|
|
|
|
| 11 |
def index():
|
| 12 |
return render_template('index.html')
|
| 13 |
|
| 14 |
+
@app.route('/health')
|
| 15 |
+
def health():
|
| 16 |
+
return 'OK', 200
|
| 17 |
+
|
| 18 |
@app.route('/static/<path:path>')
|
| 19 |
def send_static(path):
|
| 20 |
return send_from_directory('static', path)
|
templates/index.html
CHANGED
|
@@ -7,9 +7,16 @@
|
|
| 7 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
| 9 |
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
|
| 10 |
-
<
|
|
|
|
| 11 |
<style>
|
| 12 |
body { font-family: 'Noto Sans SC', sans-serif; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
.canvas-container {
|
| 14 |
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
|
| 15 |
overflow: hidden;
|
|
@@ -48,14 +55,21 @@
|
|
| 48 |
<h1 class="text-xl font-bold text-gray-800">爆款视频封面大师</h1>
|
| 49 |
</div>
|
| 50 |
<div class="flex gap-3">
|
|
|
|
|
|
|
|
|
|
| 51 |
<button @click="resetConfig" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition">
|
| 52 |
重置画布
|
| 53 |
</button>
|
| 54 |
-
<button @click="downloadImage" class="px-6 py-2 bg-black text-white rounded-lg font-bold hover:bg-gray-800 transition flex items-center gap-2 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
|
| 55 |
-
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 56 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
| 57 |
</svg>
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
</button>
|
| 60 |
</div>
|
| 61 |
</header>
|
|
@@ -103,6 +117,17 @@
|
|
| 103 |
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">主标题 (拖拽移动)</h3>
|
| 104 |
<textarea v-model="config.mainTitle.text" rows="2" class="w-full border border-gray-300 rounded-lg p-2 text-lg font-bold mb-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="输入爆款标题..."></textarea>
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
<div class="grid grid-cols-2 gap-4">
|
| 107 |
<div>
|
| 108 |
<label class="text-xs text-gray-500 block mb-1">字号 {{ config.mainTitle.size }}px</label>
|
|
@@ -237,8 +262,8 @@
|
|
| 237 |
</div>
|
| 238 |
|
| 239 |
<!-- Main Title -->
|
| 240 |
-
<div class="draggable-item absolute z-20 whitespace-pre-wrap font-black leading-tight"
|
| 241 |
-
:class="{ 'active': activeElement === 'mainTitle' }"
|
| 242 |
@mousedown="startDrag($event, 'mainTitle')"
|
| 243 |
:style="{
|
| 244 |
left: config.mainTitle.x + 'px',
|
|
@@ -531,6 +556,78 @@
|
|
| 531 |
document.removeEventListener('mouseup', stopDrag);
|
| 532 |
};
|
| 533 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
return {
|
| 535 |
config,
|
| 536 |
ratios,
|
|
|
|
| 7 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
| 9 |
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
|
| 10 |
+
<!-- 引入更多中文字体 -->
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&family=Noto+Sans+SC:wght@400;700;900&family=Noto+Serif+SC:wght@700;900&family=ZCOOL+KuaiLe&family=ZCOOL+QingKe+HuangYou&display=swap" rel="stylesheet">
|
| 12 |
<style>
|
| 13 |
body { font-family: 'Noto Sans SC', sans-serif; }
|
| 14 |
+
.font-sans { font-family: 'Noto Sans SC', sans-serif; }
|
| 15 |
+
.font-serif { font-family: 'Noto Serif SC', serif; }
|
| 16 |
+
.font-kuai-le { font-family: 'ZCOOL KuaiLe', cursive; }
|
| 17 |
+
.font-ma-shan-zheng { font-family: 'Ma Shan Zheng', cursive; }
|
| 18 |
+
.font-huang-you { font-family: 'ZCOOL QingKe HuangYou', sans-serif; }
|
| 19 |
+
|
| 20 |
.canvas-container {
|
| 21 |
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
|
| 22 |
overflow: hidden;
|
|
|
|
| 55 |
<h1 class="text-xl font-bold text-gray-800">爆款视频封面大师</h1>
|
| 56 |
</div>
|
| 57 |
<div class="flex gap-3">
|
| 58 |
+
<button @click="randomTemplate" class="px-4 py-2 text-sm bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg transition flex items-center gap-1">
|
| 59 |
+
<span class="text-lg">🎲</span> 随机灵感
|
| 60 |
+
</button>
|
| 61 |
<button @click="resetConfig" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition">
|
| 62 |
重置画布
|
| 63 |
</button>
|
| 64 |
+
<button @click="downloadImage" :disabled="isDownloading" class="px-6 py-2 bg-black text-white rounded-lg font-bold hover:bg-gray-800 transition flex items-center gap-2 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed">
|
| 65 |
+
<svg v-if="!isDownloading" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 66 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
| 67 |
</svg>
|
| 68 |
+
<svg v-else class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 69 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 70 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 71 |
+
</svg>
|
| 72 |
+
{{ isDownloading ? '生成中...' : '导出封面' }}
|
| 73 |
</button>
|
| 74 |
</div>
|
| 75 |
</header>
|
|
|
|
| 117 |
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">主标题 (拖拽移动)</h3>
|
| 118 |
<textarea v-model="config.mainTitle.text" rows="2" class="w-full border border-gray-300 rounded-lg p-2 text-lg font-bold mb-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="输入爆款标题..."></textarea>
|
| 119 |
|
| 120 |
+
<div class="mb-3">
|
| 121 |
+
<label class="text-xs text-gray-500 block mb-1">字体风格</label>
|
| 122 |
+
<select v-model="config.mainTitle.fontFamily" class="w-full border border-gray-300 rounded-lg p-2 text-sm">
|
| 123 |
+
<option value="font-sans">无衬线黑体 (现代)</option>
|
| 124 |
+
<option value="font-serif">衬线宋体 (文艺)</option>
|
| 125 |
+
<option value="font-kuai-le">快乐体 (可爱)</option>
|
| 126 |
+
<option value="font-ma-shan-zheng">马善政毛笔 (国潮)</option>
|
| 127 |
+
<option value="font-huang-you">站酷黄油 (醒目)</option>
|
| 128 |
+
</select>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
<div class="grid grid-cols-2 gap-4">
|
| 132 |
<div>
|
| 133 |
<label class="text-xs text-gray-500 block mb-1">字号 {{ config.mainTitle.size }}px</label>
|
|
|
|
| 262 |
</div>
|
| 263 |
|
| 264 |
<!-- Main Title -->
|
| 265 |
+
<div class="draggable-item absolute z-20 whitespace-pre-wrap font-black leading-tight text-center"
|
| 266 |
+
:class="[{ 'active': activeElement === 'mainTitle' }, config.mainTitle.fontFamily]"
|
| 267 |
@mousedown="startDrag($event, 'mainTitle')"
|
| 268 |
:style="{
|
| 269 |
left: config.mainTitle.x + 'px',
|
|
|
|
| 556 |
document.removeEventListener('mouseup', stopDrag);
|
| 557 |
};
|
| 558 |
|
| 559 |
+
return {
|
| 560 |
+
config,
|
| 561 |
+
ratios,
|
| 562 |
+
presets,
|
| 563 |
+
canvasStyle,
|
| 564 |
+
activeElement,
|
| 565 |
+
handleBgUpload,
|
| 566 |
+
handleFgUpload,
|
| 567 |
+
addSticker,
|
| 568 |
+
removeSticker,
|
| 569 |
+
applyTitleStyle,
|
| 570 |
+
resetConfig,
|
| 571 |
+
downloadImage,
|
| 572 |
+
startDrag,
|
| 573 |
+
randomTemplate,
|
| 574 |
+
isDownloading
|
| 575 |
+
};
|
| 576 |
+
}
|
| 577 |
+
}).mount('#app');
|
| 578 |
+
</script>
|
| 579 |
+
</body>
|
| 580 |
+
</html>
|
| 581 |
+
const rect = e.target.getBoundingClientRect();
|
| 582 |
+
// Just store the starting mouse position
|
| 583 |
+
dragOffset.startX = clientX;
|
| 584 |
+
dragOffset.startY = clientY;
|
| 585 |
+
dragOffset.initialObjX = currentX;
|
| 586 |
+
dragOffset.initialObjY = currentY;
|
| 587 |
+
|
| 588 |
+
document.addEventListener('mousemove', onDrag);
|
| 589 |
+
document.addEventListener('mouseup', stopDrag);
|
| 590 |
+
};
|
| 591 |
+
|
| 592 |
+
const onDrag = (e) => {
|
| 593 |
+
if (!isDragging) return;
|
| 594 |
+
e.preventDefault();
|
| 595 |
+
|
| 596 |
+
// Calculate scale factor from the transform
|
| 597 |
+
const element = document.getElementById('canvas');
|
| 598 |
+
// Parse scale from style string or computed style
|
| 599 |
+
const transform = element.style.transform;
|
| 600 |
+
const match = transform.match(/scale\(([^)]+)\)/);
|
| 601 |
+
const scale = match ? parseFloat(match[1]) : 1;
|
| 602 |
+
|
| 603 |
+
const dx = (e.clientX - dragOffset.startX) / scale;
|
| 604 |
+
const dy = (e.clientY - dragOffset.startY) / scale;
|
| 605 |
+
|
| 606 |
+
let newX = dragOffset.initialObjX + dx;
|
| 607 |
+
let newY = dragOffset.initialObjY + dy;
|
| 608 |
+
|
| 609 |
+
if (currentDragTarget === 'mainTitle') {
|
| 610 |
+
config.mainTitle.x = newX;
|
| 611 |
+
config.mainTitle.y = newY;
|
| 612 |
+
} else if (currentDragTarget === 'subTitle') {
|
| 613 |
+
config.subTitle.x = newX;
|
| 614 |
+
config.subTitle.y = newY;
|
| 615 |
+
} else if (currentDragTarget === 'fg') {
|
| 616 |
+
config.fgPos.x = newX;
|
| 617 |
+
config.fgPos.y = newY;
|
| 618 |
+
} else if (currentDragTarget.startsWith('sticker')) {
|
| 619 |
+
config.stickers[stickerIndex].x = newX;
|
| 620 |
+
config.stickers[stickerIndex].y = newY;
|
| 621 |
+
}
|
| 622 |
+
};
|
| 623 |
+
|
| 624 |
+
const stopDrag = () => {
|
| 625 |
+
isDragging = false;
|
| 626 |
+
currentDragTarget = null;
|
| 627 |
+
document.removeEventListener('mousemove', onDrag);
|
| 628 |
+
document.removeEventListener('mouseup', stopDrag);
|
| 629 |
+
};
|
| 630 |
+
|
| 631 |
return {
|
| 632 |
config,
|
| 633 |
ratios,
|