commit 3a6d4fc683b60f8cd29b089cc6aaf76467d03675 Author: hz4th_coder Date: Wed Aug 26 17:37:56 2026 +0800 FreeCut 自由剪辑 v1.0.0: 免费开源视频编辑器(Web) - 多轨道时间线: 视频/图片/音频/文字, 拖拽/修剪/分割/复制/删除 - 12款滤镜特效 + 4种转场 + 4种文字动画 - 6款程序化原创配乐(Web Audio 实时合成, 零版权风险) - Canvas 合成 + MediaRecorder 实时导出 WebM(VP9+Opus) - 账号体系 + 每日导出额度 + 企业/AI短剧付费升级 + 管理后台 - 项目自动保存/缩略图, 移动端自适应 - 个人免费每天30次导出, 企业¥299/月 不限量, AI短剧¥499/月 不限量 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a02064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +logs/ +data/*.db +data/uploads/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..256f6c1 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# ✂️ FreeCut 自由剪辑 + +> 免费开源视频编辑软件(Web 版)。**个人自媒体永久免费**,视频制作公司与 AI 短剧批量生产才付费。 + +![web](https://img.shields.io/badge/平台-Web(桌面+手机)-blue) ![license](https://img.shields.io/badge/开源-MIT-green) ![stack](https://img.shields.io/badge/技术-Flask+原生JS+WebAudio-brightgreen) + +--- + +## 亮点 + +- **全功能免费**:滤镜特效、程序化原创配乐、字幕、转场、多轨道剪辑,个人用户全部免费、无水印、可商用 +- **原创配乐**:6 大情绪配乐(温馨/清新/激昂/深夜/科技/欢乐)由 Web Audio 在浏览器实时合成,**零版权风险** +- **浏览器本地渲染**:Canvas 合成 + MediaRecorder 实时编码导出 WebM,素材不出设备,服务器只存工程 +- **差异化收费**:个人每天 30 次导出额度;企业 / AI 短剧付费后不限量(后台可开通) + +## 功能清单(v1.0) + +- 多轨道时间线(视频 / 音频 / 文字),拖拽移动、两端修剪、分割、复制、删除 +- 12 款滤镜特效 + 4 种转场 + 4 种文字动画 +- 6 款原创配乐(可试听切换/移除,音量调节) +- 画布比例 9:16 / 16:9 / 1:1,帧率 24/30/60 +- 账号体系 + 每日导出额度 + 付费升级(企业/AI短剧)+ 管理后台 +- 项目自动保存 + 缩略图;WebM 导出(VP9) + +## 快速开始 + +```bash +./start.sh # 默认端口 16106 +./start.sh stop # 停止 +``` + +访问 `http://:16106/` + +- 首页注册即可用(免费) +- 管理后台:`http://:16106/admin`,账号 `admin` / `admin123`(可开通企业/AI短剧付费) + +## 技术栈 + +- 后端:Python Flask + SQLite(用户 / 项目 / 配额 / 升级申请) +- 前端:原生 HTML/JS/CSS,无构建步骤,移动端自适应 +- 渲染:`renderer.js`(Canvas 合成 + 转场/滤镜/字幕)、`music.js`(程序化配乐)、MediaRecorder 导出 + +## 目录结构 + +``` +app.py # Flask 后端(认证/项目/配额/上传/后台) +static/ + index.html # 首页(定价/登录/作品) + editor.html # 编辑器 + admin.html # 管理后台 + js/editor.js # 编辑器逻辑(时间线/属性/导出) + js/renderer.js # 渲染引擎(播放/导出/滤镜/转场) + js/music.js # 程序化配乐生成器 +data/ # SQLite + 上传文件 +``` + +## 配额与收费(理念) + +| 版本 | 价格 | 额度 | 适用 | +|------|------|------|------| +| 个人免费版 | ¥0 | 每天 30 次导出 | 普通自媒体、个人创作者 | +| 企业版 | ¥299/月 | 不限量 | 视频制作公司 | +| AI 短剧版 | ¥499/月 | 不限量 | AI 短剧批量生产 | + +## 路线图 + +- [ ] 更多滤镜与转场(粒子、光效) +- [ ] 关键帧动画 +- [ ] 更多导出格式(MP4/MP3)与服务端编码 +- [ ] 模板市场 / 一键成片 +- [ ] 团队协作 +- [ ] 桌面 / 移动客户端(用户量起来后) + +## 开源协议 + +MIT diff --git a/app.py b/app.py new file mode 100644 index 0000000..115dcb4 --- /dev/null +++ b/app.py @@ -0,0 +1,478 @@ +# -*- coding: utf-8 -*- +""" +FreeCut 自由剪辑 - 免费开源视频编辑软件 (Web) +个人自媒体免费 / 企业 & AI短剧付费 +""" +import os +import sqlite3 +import time +import json +import functools +import threading +from datetime import datetime, date + +from flask import (Flask, request, session, redirect, jsonify, send_from_directory, + render_template_string, g, abort) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, 'data') +DB_PATH = os.path.join(DATA_DIR, 'freecut.db') +STATIC_DIR = os.path.join(BASE_DIR, 'static') + +os.makedirs(DATA_DIR, exist_ok=True) + +app = Flask(__name__, static_folder=STATIC_DIR, static_url_path='') +app.secret_key = 'freecut-secret-2026-change-me' +app.config['MAX_CONTENT_LENGTH'] = 800 * 1024 * 1024 # 800MB 上传 + +_db_lock = threading.RLock() + +# ---------------- 配额配置 ---------------- +# 免费个人用户:每天 N 次导出(足够自媒体日常使用) +# 企业 / AI短剧:付费后不限量 +QUOTA = { + 'free': {'exports_per_day': 30, 'label': '个人免费版', 'desc': '无水印、全功能,每天 30 次导出,够自媒体日常使用'}, + 'pro': {'exports_per_day': 100, 'label': '个人专业版', 'desc': '更高额度'}, + 'business': {'exports_per_day': 0, 'label': '企业版', 'desc': '付费 · 不限量导出', 'paid': True}, + 'ai_short': {'exports_per_day': 0, 'label': 'AI短剧版', 'desc': '付费 · 不限量导出', 'paid': True}, +} +TIER_NAMES = {'free': '个人免费版', 'pro': '个人专业版', 'business': '企业版', 'ai_short': 'AI短剧版'} + + +def get_db(): + if 'db' not in g: + g.db = sqlite3.connect(DB_PATH, timeout=30) + g.db.row_factory = sqlite3.Row + return g.db + + +@app.teardown_appcontext +def close_db(exc): + db = g.pop('db', None) + if db is not None: + db.close() + + +def init_db(): + with _db_lock: + db = sqlite3.connect(DB_PATH, timeout=30) + db.executescript(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, -- sha256 + nickname TEXT DEFAULT '', + tier TEXT DEFAULT 'free', -- free/pro/business/ai_short + paid_expire INTEGER DEFAULT 0, -- 0=未付费, 否则到期时间戳 + created_at INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT DEFAULT '未命名项目', + data TEXT DEFAULT '{}', -- 工程JSON + thumb TEXT DEFAULT '', + updated_at INTEGER DEFAULT 0, + created_at INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS export_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + project_id INTEGER DEFAULT 0, + date TEXT NOT NULL, + duration INTEGER DEFAULT 0, + size INTEGER DEFAULT 0, + created_at INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS upgrade_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + target_tier TEXT NOT NULL, + contact TEXT DEFAULT '', + status TEXT DEFAULT 'pending', -- pending/approved/rejected + created_at INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT 0, + name TEXT DEFAULT '', + kind TEXT DEFAULT 'video', -- video/image/audio + path TEXT DEFAULT '', + size INTEGER DEFAULT 0, + created_at INTEGER DEFAULT 0 + ); + ''') + # 默认管理员 + import hashlib + cur = db.execute("SELECT id FROM users WHERE username='admin'") + if not cur.fetchone(): + db.execute("INSERT INTO users(username,password,nickname,tier,created_at) VALUES(?,?,?,?,?)", + ('admin', hashlib.sha256('admin123'.encode()).hexdigest(), '管理员', 'business', int(time.time()))) + db.commit() + db.close() + + +init_db() + + +def hash_pw(pw): + import hashlib + return hashlib.sha256(pw.encode()).hexdigest() + + +def now(): + return int(time.time()) + + +def login_required(fn): + @functools.wraps(fn) + def wrapper(*a, **kw): + if not session.get('uid'): + return jsonify({'ok': False, 'error': '未登录', 'code': 401}) + return fn(*a, **kw) + return wrapper + + +def admin_required(fn): + @functools.wraps(fn) + def wrapper(*a, **kw): + if session.get('uid') != 1: + return jsonify({'ok': False, 'error': '需要管理员权限'}) + return fn(*a, **kw) + return wrapper + + +def get_user(uid=None): + db = get_db() + row = db.execute("SELECT * FROM users WHERE id=?", (uid or session.get('uid'),)).fetchone() + return dict(row) if row else None + + +def today_usage(uid, d=None): + d = d or date.today().isoformat() + db = get_db() + row = db.execute("SELECT COUNT(*), COALESCE(SUM(size),0) FROM export_logs WHERE user_id=? AND date=?", + (uid, d)).fetchone() + return row[0], row[1] or 0 + + +def quota_status(uid): + u = get_user(uid) + if not u: + return {'ok': False} + tier = u['tier'] + conf = QUOTA.get(tier, QUOTA['free']) + used, size = today_usage(uid) + paid = u.get('paid_expire', 0) > now() + if conf['exports_per_day'] == 0: + # 付费版本:已付费则不限量,否则回退到免费额度(保留等级名) + limit = 0 if paid else QUOTA['free']['exports_per_day'] + else: + limit = conf['exports_per_day'] + return { + 'ok': True, + 'username': u['username'], + 'nickname': u['nickname'] or u['username'], + 'tier': tier, + 'tier_name': conf['label'], + 'tier_desc': conf['desc'], + 'paid': paid, + 'exports_per_day': limit, + 'exports_used': used, + 'exports_left': 0 if limit == 0 else max(0, limit - used), + 'today_bytes': size, + 'date': date.today().isoformat(), + } + + +# ---------------- 页面 ---------------- +@app.route('/') +def index(): + return send_from_directory(STATIC_DIR, 'index.html') + + +@app.route('/editor') +@login_required +def editor_page(): + return send_from_directory(STATIC_DIR, 'editor.html') + + +@app.route('/admin') +@admin_required +def admin_page(): + return send_from_directory(STATIC_DIR, 'admin.html') + + +# ---------------- 认证 ---------------- +@app.route('/api/auth/register', methods=['POST']) +def register(): + d = request.get_json(force=True, silent=True) or {} + username = (d.get('username') or '').strip() + password = d.get('password') or '' + nickname = (d.get('nickname') or '').strip() or username + if len(username) < 2 or len(password) < 4: + return jsonify({'ok': False, 'error': '用户名至少2位,密码至少4位'}) + db = get_db() + if db.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone(): + return jsonify({'ok': False, 'error': '用户名已被占用'}) + db.execute("INSERT INTO users(username,password,nickname,tier,created_at) VALUES(?,?,?,?,?)", + (username, hash_pw(password), nickname, 'free', now())) + db.commit() + return jsonify({'ok': True}) + + +@app.route('/api/auth/login', methods=['POST']) +def login(): + d = request.get_json(force=True, silent=True) or {} + username = (d.get('username') or '').strip() + password = d.get('password') or '' + db = get_db() + row = db.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + if not row or row['password'] != hash_pw(password): + return jsonify({'ok': False, 'error': '用户名或密码错误'}) + session['uid'] = row['id'] + session['username'] = row['username'] + return jsonify({'ok': True, 'user': {'username': row['username'], 'nickname': row['nickname'] or row['username'], 'tier': row['tier']}}) + + +@app.route('/api/auth/logout', methods=['POST']) +def logout(): + session.clear() + return jsonify({'ok': True}) + + +@app.route('/api/me') +@login_required +def me(): + u = get_user() + if not u: + session.clear() + return jsonify({'ok': False, 'error': '账号不存在,请重新登录', 'code': 401}) + return jsonify({'ok': True, 'quota': quota_status(session['uid'])}) + + +# ---------------- 项目 ---------------- +@app.route('/api/projects', methods=['GET', 'POST']) +@login_required +def projects(): + uid = session['uid'] + db = get_db() + if request.method == 'POST': + d = request.get_json(force=True, silent=True) or {} + name = (d.get('name') or '').strip() or '未命名项目' + cur = db.execute("INSERT INTO projects(user_id,name,data,updated_at,created_at) VALUES(?,?,?,?,?)", + (uid, name, '{}', now(), now())) + db.commit() + return jsonify({'ok': True, 'id': cur.lastrowid}) + rows = db.execute("SELECT id,name,thumb,updated_at,created_at FROM projects WHERE user_id=? ORDER BY updated_at DESC", (uid,)).fetchall() + return jsonify({'ok': True, 'projects': [dict(r) for r in rows]}) + + +@app.route('/api/projects/', methods=['GET', 'PUT', 'DELETE']) +@login_required +def project_detail(pid): + uid = session['uid'] + db = get_db() + row = db.execute("SELECT * FROM projects WHERE id=? AND user_id=?", (pid, uid)).fetchone() + if not row: + return jsonify({'ok': False, 'error': '项目不存在'}) + if request.method == 'GET': + return jsonify({'ok': True, 'project': {'id': row['id'], 'name': row['name'], 'data': json.loads(row['data'] or '{}'), 'thumb': row['thumb']}}) + if request.method == 'DELETE': + db.execute("DELETE FROM projects WHERE id=?", (pid,)) + db.commit() + return jsonify({'ok': True}) + d = request.get_json(force=True, silent=True) or {} + data = json.dumps(d.get('data', {}), ensure_ascii=False) + name = (d.get('name') or '').strip() or row['name'] + thumb = d.get('thumb') or row['thumb'] + db.execute("UPDATE projects SET name=?, data=?, thumb=?, updated_at=? WHERE id=?", (name, data, thumb, now(), pid)) + db.commit() + return jsonify({'ok': True}) + + +# ---------------- 配额 ---------------- +@app.route('/api/quota') +@login_required +def quota_api(): + return jsonify({'ok': True, 'quota': quota_status(session['uid'])}) + + +@app.route('/api/quota/check-export', methods=['POST']) +@login_required +def check_export(): + uid = session['uid'] + q = quota_status(uid) + if q['exports_per_day'] != 0 and q['exports_left'] <= 0: + return jsonify({'ok': False, 'error': f"今日导出额度已用完({q['exports_per_day']}次/天)。个人免费版每天额度充足,明天自动恢复;企业/AI短剧请付费解锁不限量。", 'quota': q}) + return jsonify({'ok': True, 'quota': q}) + + +@app.route('/api/quota/record-export', methods=['POST']) +@login_required +def record_export(): + d = request.get_json(force=True, silent=True) or {} + pid = int(d.get('project_id') or 0) + duration = int(d.get('duration') or 0) + size = int(d.get('size') or 0) + db = get_db() + db.execute("INSERT INTO export_logs(user_id,project_id,date,duration,size,created_at) VALUES(?,?,?,?,?,?)", + (session['uid'], pid, date.today().isoformat(), duration, size, now())) + db.commit() + return jsonify({'ok': True, 'quota': quota_status(session['uid'])}) + + +# ---------------- 升级/付费 ---------------- +@app.route('/api/upgrade/plans') +def plans(): + return jsonify({'ok': True, 'plans': { + 'business': {'name': '企业版', 'price': '¥299/月', 'features': ['不限量导出', '团队协作(规划中)', '优先渲染(规划中)', '商用授权']}, + 'ai_short': {'name': 'AI短剧版', 'price': '¥499/月', 'features': ['不限量导出', '批量生产(规划中)', '商用授权', '独享高速通道(规划中)']}, + }}) + + +@app.route('/api/upgrade/apply', methods=['POST']) +@login_required +def apply_upgrade(): + d = request.get_json(force=True, silent=True) or {} + target = d.get('tier') + if target not in ('business', 'ai_short'): + return jsonify({'ok': False, 'error': '只能申请企业版/AI短剧版'}) + contact = (d.get('contact') or '').strip() + db = get_db() + # 避免重复 pending + dup = db.execute("SELECT id FROM upgrade_requests WHERE user_id=? AND target_tier=? AND status='pending'", + (session['uid'], target)).fetchone() + if dup: + return jsonify({'ok': False, 'error': '已有待审核申请,请耐心等待管理员开通'}) + db.execute("INSERT INTO upgrade_requests(user_id,target_tier,contact,created_at) VALUES(?,?,?,?)", + (session['uid'], target, contact, now())) + db.commit() + return jsonify({'ok': True}) + + +# ---------------- 上传资源 ---------------- +@app.route('/api/assets', methods=['GET']) +@login_required +def list_assets(): + db = get_db() + rows = db.execute("SELECT * FROM assets WHERE user_id=? ORDER BY id DESC", (session['uid'],)).fetchall() + out = [] + for r in rows: + d = dict(r) + d['url'] = r['path'] + out.append(d) + return jsonify({'ok': True, 'assets': out}) + + +@app.route('/api/assets', methods=['POST']) +@login_required +def upload_asset(): + f = request.files.get('file') + if not f or not f.filename: + return jsonify({'ok': False, 'error': '未选择文件'}) + kind = request.form.get('kind', 'video') + ext = os.path.splitext(f.filename)[1].lower() or '.bin' + if kind == 'video' and ext not in ('.mp4', '.webm', '.mov', '.m4v', '.mkv'): + return jsonify({'ok': False, 'error': '不支持的视频格式'}) + if kind == 'image' and ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp'): + return jsonify({'ok': False, 'error': '不支持的图片格式'}) + if kind == 'audio' and ext not in ('.mp3', '.wav', '.m4a', '.ogg', '.aac'): + return jsonify({'ok': False, 'error': '不支持的音频格式'}) + fname = f'{int(time.time()*1000)}_{os.urandom(3).hex()}{ext}' + fpath = os.path.join(DATA_DIR, 'uploads') + os.makedirs(fpath, exist_ok=True) + f.save(os.path.join(fpath, fname)) + db = get_db() + cur = db.execute("INSERT INTO assets(user_id,name,kind,path,size,created_at) VALUES(?,?,?,?,?,?)", + (session['uid'], f.filename, kind, f'/uploads/{fname}', os.path.getsize(os.path.join(fpath, fname)), now())) + db.commit() + return jsonify({'ok': True, 'asset': {'id': cur.lastrowid, 'name': f.filename, 'kind': kind, 'url': f'/uploads/{fname}', 'size': os.path.getsize(os.path.join(fpath, fname))}}) + + +@app.route('/uploads/') +def uploads(fn): + return send_from_directory(os.path.join(DATA_DIR, 'uploads'), fn) + + +# ---------------- 管理后台 ---------------- +@app.route('/api/admin/users') +@admin_required +def admin_users(): + db = get_db() + rows = db.execute("SELECT id,username,nickname,tier,paid_expire,created_at FROM users ORDER BY id").fetchall() + out = [] + for r in rows: + used, _ = today_usage(r['id']) + out.append({**dict(r), 'today_exports': used}) + return jsonify({'ok': True, 'users': out}) + + +@app.route('/api/admin/user', methods=['POST']) +@admin_required +def admin_set_user(): + d = request.get_json(force=True, silent=True) or {} + uid = int(d.get('id') or 0) + tier = d.get('tier') + if tier not in TIER_NAMES: + return jsonify({'ok': False, 'error': '非法等级'}) + paid_expire = int(d.get('paid_expire') or 0) + db = get_db() + db.execute("UPDATE users SET tier=?, paid_expire=? WHERE id=?", (tier, paid_expire, uid)) + db.commit() + return jsonify({'ok': True}) + + +@app.route('/api/admin/requests') +@admin_required +def admin_requests(): + db = get_db() + rows = db.execute("SELECT r.*, u.username FROM upgrade_requests r LEFT JOIN users u ON u.id=r.user_id ORDER BY r.id DESC").fetchall() + return jsonify({'ok': True, 'requests': [dict(r) for r in rows]}) + + +@app.route('/api/admin/request', methods=['POST']) +@admin_required +def admin_handle_request(): + d = request.get_json(force=True, silent=True) or {} + rid = int(d.get('id') or 0) + action = d.get('action') + db = get_db() + r = db.execute("SELECT * FROM upgrade_requests WHERE id=?", (rid,)).fetchone() + if not r: + return jsonify({'ok': False, 'error': '申请不存在'}) + if action == 'approve': + # 付费 1 年 + expire = now() + 365 * 24 * 3600 + db.execute("UPDATE users SET tier=?, paid_expire=? WHERE id=?", (r['target_tier'], expire, r['user_id'])) + db.execute("UPDATE upgrade_requests SET status='approved' WHERE id=?", (rid,)) + else: + db.execute("UPDATE upgrade_requests SET status='rejected' WHERE id=?", (rid,)) + db.commit() + return jsonify({'ok': True}) + + +@app.route('/api/admin/stats') +@admin_required +def admin_stats(): + db = get_db() + u = db.execute("SELECT COUNT(*) c FROM users").fetchone()['c'] + p = db.execute("SELECT COUNT(*) c FROM projects").fetchone()['c'] + e = db.execute("SELECT COUNT(*) c FROM export_logs").fetchone()['c'] + # 近7天导出 + days = [] + for i in range(6, -1, -1): + d = (date.today().fromordinal(date.today().toordinal() - i)).isoformat() + c = db.execute("SELECT COUNT(*) c FROM export_logs WHERE date=?", (d,)).fetchone()['c'] + days.append({'date': d, 'count': c}) + return jsonify({'ok': True, 'users': u, 'projects': p, 'exports': e, 'days': days}) + + +@app.route('/api/ping') +def ping(): + return jsonify({'ok': True, 'name': 'FreeCut 自由剪辑', 'version': 'v1.0.0', 'time': now()}) + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', '16106')) + app.run(host='0.0.0.0', port=port, debug=False, threaded=True) diff --git a/docs/fc_editor2.png b/docs/fc_editor2.png new file mode 100644 index 0000000..23e3e54 Binary files /dev/null and b/docs/fc_editor2.png differ diff --git a/docs/fc_home.png b/docs/fc_home.png new file mode 100644 index 0000000..5c94f81 Binary files /dev/null and b/docs/fc_home.png differ diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..b9d2d6d --- /dev/null +++ b/start.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# FreeCut 自由剪辑 启动脚本 +cd "$(dirname "$0")" +PORT=${PORT:-16106} +PY=/home/hz1/miniconda3/envs/openclaw/bin/python3 +LOG=logs/app.log +mkdir -p logs + +case "${1:-start}" in + start) + if [ -f logs/app.pid ] && kill -0 "$(cat logs/app.pid)" 2>/dev/null; then + echo "FreeCut 已在运行 (PID $(cat logs/app.pid), 端口 $PORT)" + exit 0 + fi + nohup env PORT=$PORT $PY app.py >> "$LOG" 2>&1 & + echo $! > logs/app.pid + sleep 1.2 + echo "FreeCut 已启动: http://$(hostname -I 2>/dev/null | awk '{print $1}'):$PORT/ (PID $(cat logs/app.pid))" + ;; + stop) + if [ -f logs/app.pid ] && kill -0 "$(cat logs/app.pid)" 2>/dev/null; then + kill "$(cat logs/app.pid)" && echo "已停止" + rm -f logs/app.pid + else + echo "未在运行" + fi + ;; + restart) "$0" stop; sleep 0.6; "$0" start ;; + status) + if [ -f logs/app.pid ] && kill -0 "$(cat logs/app.pid)" 2>/dev/null; then + echo "运行中 (PID $(cat logs/app.pid), 端口 $PORT)" + else + echo "未运行" + fi + ;; + *) echo "用法: ./start.sh [start|stop|restart|status]";; +esac diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..a1d596c --- /dev/null +++ b/static/admin.html @@ -0,0 +1,102 @@ + + + + + +FreeCut · 管理后台 + + + + + + +
+
+ +

📋 升级申请

+
+ +

👥 用户管理

+ + + +
ID用户名昵称等级付费到期今日导出操作
+
+ + + + diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..512cdc1 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,213 @@ +:root { + --bg: #0f1117; + --bg2: #171a23; + --bg3: #1e2230; + --line: #2a2f42; + --text: #e8eaf2; + --muted: #8b93a7; + --accent: #6c5ce7; + --accent2: #00d2ff; + --ok: #2ecc71; + --warn: #f39c12; + --err: #e74c3c; + --grad: linear-gradient(135deg, #6c5ce7, #00d2ff); + --radius: 12px; + --shadow: 0 8px 30px rgba(0,0,0,.35); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.6; +} +a { color: var(--accent2); text-decoration: none; } +button { font-family: inherit; cursor: pointer; } +input, select, textarea { + font-family: inherit; background: var(--bg3); color: var(--text); + border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; outline: none; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); } +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { background: #333a52; border-radius: 4px; } +::-webkit-scrollbar-track { background: transparent; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + background: var(--grad); color: #fff; border: none; border-radius: 10px; + padding: 9px 18px; font-size: 14px; font-weight: 600; transition: transform .12s, opacity .12s; +} +.btn:hover { transform: translateY(-1px); opacity: .92; } +.btn:active { transform: translateY(0); } +.btn.ghost { background: var(--bg3); border: 1px solid var(--line); color: var(--text); } +.btn.danger { background: linear-gradient(135deg, #e74c3c, #c0392b); } +.btn.small { padding: 5px 12px; font-size: 12px; border-radius: 8px; } +.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; } +.btn.block { width: 100%; } + +.hidden { display: none !important; } +.muted { color: var(--muted); } +.tag { + display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; +} +.tag.free { background: rgba(46,204,113,.15); color: var(--ok); } +.tag.pro { background: rgba(108,92,231,.2); color: #a29bfe; } +.tag.business { background: rgba(0,210,255,.15); color: var(--accent2); } +.tag.ai { background: rgba(243,156,18,.15); color: var(--warn); } + +/* ---------- toast ---------- */ +#toast-wrap { position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 8px; align-items: center; } +.toast { + background: var(--bg3); border: 1px solid var(--line); color: var(--text); + padding: 10px 18px; border-radius: 10px; box-shadow: var(--shadow); + animation: toastIn .25s ease; max-width: 90vw; +} +.toast.err { border-color: var(--err); color: #ff8f8f; } +.toast.ok { border-color: var(--ok); color: #7ce0a5; } +@keyframes toastIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } + +/* ---------- modal ---------- */ +.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; } +.modal { + background: var(--bg2); border: 1px solid var(--line); border-radius: 14px; + width: 100%; max-width: 460px; max-height: 88vh; overflow: auto; box-shadow: var(--shadow); padding: 24px; +} +.modal h3 { margin-bottom: 14px; } +.modal .row { margin-bottom: 12px; } +.modal .row label { display: block; margin-bottom: 5px; color: var(--muted); font-size: 13px; } + +/* ---------- 首页 ---------- */ +.land-nav { + position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 16px; + padding: 12px 5vw; background: rgba(15,17,23,.85); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); +} +.land-nav .logo { font-size: 18px; font-weight: 800; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; white-space: nowrap; } +.land-nav .spacer { flex: 1; } +.hero { padding: 56px 5vw 40px; text-align: center; } +.hero h1 { font-size: clamp(28px, 5vw, 52px); font-weight: 900; line-height: 1.2; margin-bottom: 14px; } +.hero h1 .grad { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.hero p { color: var(--muted); max-width: 640px; margin: 0 auto 26px; font-size: clamp(14px, 2vw, 17px); } +.hero .cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; } +.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; padding: 20px 5vw 10px; } +.feature { background: var(--bg2); border: 1px solid var(--line); border-radius: 14px; padding: 20px; } +.feature .ic { font-size: 26px; margin-bottom: 10px; } +.feature h3 { font-size: 16px; margin-bottom: 6px; } +.feature p { color: var(--muted); font-size: 13px; } +.pricing { padding: 40px 5vw; } +.pricing h2 { text-align: center; margin-bottom: 24px; font-size: 24px; } +.price-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; max-width: 900px; margin: 0 auto; } +.price-card { background: var(--bg2); border: 1px solid var(--line); border-radius: 16px; padding: 24px; position: relative; } +.price-card.hot { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--shadow); } +.price-card .p-name { font-size: 17px; font-weight: 700; } +.price-card .p-price { font-size: 28px; font-weight: 900; margin: 10px 0 4px; } +.price-card .p-price small { font-size: 13px; font-weight: 400; color: var(--muted); } +.price-card ul { list-style: none; margin: 14px 0 18px; } +.price-card li { padding: 4px 0; color: var(--muted); font-size: 13px; } +.price-card li::before { content: "✓ "; color: var(--ok); } +.footer { text-align: center; color: var(--muted); padding: 30px 5vw 40px; font-size: 13px; border-top: 1px solid var(--line); margin-top: 20px; } + +/* ---------- 项目列表 ---------- */ +.app-shell { max-width: 1080px; margin: 0 auto; padding: 20px 16px 60px; } +.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-top: 18px; } +.proj-card { background: var(--bg2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; cursor: pointer; transition: transform .15s, border-color .15s; } +.proj-card:hover { transform: translateY(-3px); border-color: var(--accent); } +.proj-thumb { aspect-ratio: 16/9; background: linear-gradient(135deg,#1e2230,#171a23); display: flex; align-items: center; justify-content: center; font-size: 34px; } +.proj-info { padding: 12px 14px; } +.proj-info .t { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.proj-info .s { color: var(--muted); font-size: 12px; } +.empty { text-align: center; color: var(--muted); padding: 60px 0; } + +/* ---------- 编辑器 ---------- */ +.editor { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } +.ed-top { + display: flex; align-items: center; gap: 10px; padding: 8px 12px; + background: var(--bg2); border-bottom: 1px solid var(--line); flex-wrap: wrap; +} +.ed-top .logo { font-weight: 800; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; font-size: 16px; } +.ed-top input[name=projname] { border: none; background: transparent; font-weight: 600; width: 160px; padding: 4px 8px; } +.ed-top input[name=projname]:focus { background: var(--bg3); } +.ed-main { flex: 1; display: flex; min-height: 0; } +/* 素材面板 */ +.ed-left { width: 250px; background: var(--bg2); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; } +.tabs { display: flex; border-bottom: 1px solid var(--line); } +.tabs .tab { flex: 1; text-align: center; padding: 10px 4px; cursor: pointer; color: var(--muted); font-size: 13px; border-bottom: 2px solid transparent; } +.tabs .tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } +.ed-left .body { flex: 1; overflow-y: auto; padding: 10px; } +.asset-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.asset-item { background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; cursor: grab; position: relative; } +.asset-item:active { cursor: grabbing; } +.asset-item video, .asset-item img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #000; } +.asset-item .nm { padding: 4px 6px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } +.asset-item .dur { position: absolute; right: 4px; bottom: 20px; background: rgba(0,0,0,.7); font-size: 10px; padding: 1px 5px; border-radius: 4px; } +.music-item, .filter-item, .trans-item { background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; cursor: pointer; } +.music-item:hover, .filter-item:hover, .trans-item:hover { border-color: var(--accent); } +.music-item .nm { font-weight: 600; font-size: 13px; } +.music-item .desc { font-size: 11px; color: var(--muted); } +.filter-item { display: flex; align-items: center; gap: 8px; } +.filter-item .swatch { width: 26px; height: 26px; border-radius: 6px; background: linear-gradient(135deg,#888,#444); flex-shrink: 0; border: 1px solid var(--line); } +.upload-drop { border: 1.5px dashed var(--line); border-radius: 10px; padding: 18px 8px; text-align: center; color: var(--muted); cursor: pointer; margin-bottom: 10px; } +.upload-drop:hover { border-color: var(--accent); color: var(--text); } +.upload-drop input { display: none; } + +/* 中间预览 */ +.ed-center { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #0b0d12; } +.preview-wrap { flex: 1; display: flex; align-items: center; justify-content: center; padding: 10px; min-height: 0; position: relative; } +#preview { background: #000; border-radius: 6px; box-shadow: var(--shadow); max-width: 100%; max-height: 100%; } +.ratio-toggle { position: absolute; top: 12px; right: 12px; display: flex; gap: 4px; background: rgba(0,0,0,.6); border-radius: 8px; padding: 3px; } +.ratio-toggle button { background: transparent; border: none; color: var(--muted); padding: 4px 8px; border-radius: 6px; font-size: 12px; } +.ratio-toggle button.active { background: var(--accent); color: #fff; } +.play-bar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: var(--bg2); border-top: 1px solid var(--line); } +.play-bar .time { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px; min-width: 110px; } +#seekbar { flex: 1; -webkit-appearance: none; appearance: none; height: 4px; border-radius: 2px; background: var(--line); outline: none; } +#seekbar::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); cursor: pointer; } +.btn-play { width: 40px; height: 40px; border-radius: 50%; background: var(--grad); border: none; color: #fff; font-size: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } + +/* 时间线 */ +.timeline { background: var(--bg2); border-top: 1px solid var(--line); height: 240px; display: flex; flex-direction: column; } +.tl-head { display: flex; padding: 6px 10px; gap: 10px; align-items: center; border-bottom: 1px solid var(--line); } +.tl-head .lbl { color: var(--muted); font-size: 13px; font-weight: 600; } +.timeline .inner { flex: 1; overflow: auto; position: relative; } +.tl-tracks { position: relative; } +.tl-track { height: 48px; border-bottom: 1px solid var(--line); position: relative; } +.tl-track .tk-name { position: absolute; left: 0; top: 0; bottom: 0; width: 60px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: var(--muted); background: var(--bg3); z-index: 3; border-right: 1px solid var(--line); } +.tl-ruler { height: 22px; position: relative; border-bottom: 1px solid var(--line); overflow: hidden; } +.tl-clip { position: absolute; top: 6px; height: 36px; border-radius: 6px; background: var(--bg3); border: 1px solid var(--line); display: flex; align-items: center; padding: 0 6px; gap: 4px; font-size: 11px; overflow: hidden; cursor: pointer; user-select: none; } +.tl-clip.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.tl-clip .trim { position: absolute; top: 0; bottom: 0; width: 8px; background: rgba(255,255,255,.2); cursor: ew-resize; } +.tl-clip .trim.l { left: 0; } .tl-clip .trim.r { right: 0; } +.tl-clip.video { background: rgba(108,92,231,.25); } +.tl-clip.audio { background: rgba(0,210,255,.22); } +.tl-clip.text { background: rgba(46,204,113,.22); } +.tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--accent2); z-index: 5; pointer-events: none; } +.tl-playhead::before { content: ""; position: absolute; top: 0; left: -5px; border: 6px solid transparent; border-top-color: var(--accent2); } + +/* 右侧属性 */ +.ed-right { width: 250px; background: var(--bg2); border-left: 1px solid var(--line); overflow-y: auto; padding: 12px; } +.prop-group { margin-bottom: 16px; } +.prop-group h4 { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; } +.prop-row { margin-bottom: 8px; } +.prop-row label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; } +.prop-row input[type=range] { width: 100%; accent-color: var(--accent); } +.seg { display: flex; gap: 4px; flex-wrap: wrap; } +.seg button { flex: 1; background: var(--bg3); border: 1px solid var(--line); color: var(--text); padding: 5px 4px; border-radius: 6px; font-size: 11px; } +.seg button.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* 导出 */ +.export-bar { display: flex; gap: 10px; align-items: center; padding: 10px 14px; background: var(--bg2); border-top: 1px solid var(--line); flex-wrap: wrap; } +.export-bar .spacer { flex: 1; } +.progress-wrap { width: 240px; background: var(--bg3); border-radius: 6px; height: 8px; overflow: hidden; } +.progress-wrap .bar { height: 100%; background: var(--grad); width: 0%; transition: width .2s; } + +/* 移动端适配 */ +@media (max-width: 760px) { + .ed-main { flex-direction: column; } + .ed-left { width: 100%; height: 30%; border-right: none; border-bottom: 1px solid var(--line); } + .ed-right { width: 100%; height: 26%; border-left: none; border-top: 1px solid var(--line); } + .timeline { height: 30%; } + .tl-track { height: 42px; } + .tl-clip { height: 32px; top: 5px; } + .ed-top { font-size: 12px; } + .progress-wrap { width: 140px; } +} diff --git a/static/editor.html b/static/editor.html new file mode 100644 index 0000000..a6dcdaa --- /dev/null +++ b/static/editor.html @@ -0,0 +1,115 @@ + + + + + +FreeCut 自由剪辑 · 编辑器 + + + + +
+ +
+ + + + + + +
+ +
+ +
+
+
素材
+
配乐
+
特效
+
转场
+
+
+
+
📁 点击上传 / 拖入文件
视频 · 图片 · 音频 + +
+
+
+ + + +
+
+ + +
+
+ +
+ + + +
+
+
+ + 00:00 / 00:00 + + + + + +
+ +
+
+ 时间线 + + 点选片段 · 拖动调整 · 左右端修剪 +
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..5bfdfcc --- /dev/null +++ b/static/index.html @@ -0,0 +1,243 @@ + + + + + +FreeCut 自由剪辑 · 免费开源视频编辑器 + + + + + + + +
+

免费开源,剪出大片
自媒体人的剪辑神器

+

FreeCut 是一套完全免费的在线视频编辑器:精美特效、程序化原创配乐、文字字幕、转场动画,浏览器直接出片。 + 个人自媒体永久免费,视频制作公司与 AI 短剧批量生产才需要付费。

+
+ + 查看免费额度 +
+
+ +
+
🎨

精美特效

12 款一键滤镜:电影、复古、赛博、梦幻… 转场动画与文字动画任你选。

+
🎼

优秀配乐

6 大情绪原创配乐,浏览器端实时合成、零版权风险,随时商用。

+
✂️

专业剪辑

多轨道时间线、剪辑分割、拖动修剪、音量控制、画布比例(9:16/16:9/1:1)。

+
🔒

本地渲染

视频在浏览器本地编码,素材隐私不出设备,服务器只存工程文件。

+
💎

个人免费

个人自媒体每天 30 次导出额度,无水印、全功能,足够日常使用。

+
🌱

开源可部署

纯 Flask + 原生前端,开源代码随意部署,数据都在自己手里。

+
+ +
+

💰 定价:普通人免费,生产方付费

+
+
+
🆓 个人免费版
+
¥0 / 永久
+
    +
  • 全部功能免费使用
  • +
  • 每天 30 次导出额度(够用)
  • +
  • 无水印、可商用
  • +
  • 特效 / 配乐 / 字幕全开放
  • +
+ +
+
+
🏢 企业版
+
¥299 / 月
+
    +
  • 不限量导出
  • +
  • 团队协作(规划中)
  • +
  • 优先渲染通道(规划中)
  • +
  • 商用授权 + 技术支持
  • +
+ +
+
+
🎬 AI 短剧版
+
¥499 / 月
+
    +
  • 不限量导出
  • +
  • 批量生产流水线(规划中)
  • +
  • 独享高速编码(规划中)
  • +
  • 商用授权 + 专属支持
  • +
+ +
+
+
+ +
+
🧭

创作流程

① 上传或选择素材 → ② 拖入时间线,点击修剪分割 → ③ 一键加特效、配乐、转场、字幕 → ④ 导出 WebM 视频,直接发布。全部在浏览器完成。

+
+ +
+
✂️ FreeCut 自由剪辑 · 免费开源 · 个人自媒体免费,企业与 AI 短剧付费
+
构建于 Flask + 原生 JS + Web Audio / Canvas / MediaRecorder · 浏览器本地渲染
+
© 2026 FreeCut · 开源协议(MIT)· 部署:管理后台
+
+ + + + + + + + + + + diff --git a/static/js/common.js b/static/js/common.js new file mode 100644 index 0000000..92a1b67 --- /dev/null +++ b/static/js/common.js @@ -0,0 +1,41 @@ +// FreeCut 公共工具 +const FC = (() => { + async function api(path, opts = {}) { + const init = { method: opts.method || 'GET', credentials: 'same-origin' }; + if (opts.body !== undefined) { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(opts.body); + } + const res = await fetch(path, init); + let data = {}; + try { data = await res.json(); } catch (e) { data = {}; } + if (!res.ok) throw new Error(data.error || ('请求失败 ' + res.status)); + if (data.ok === false) throw new Error(data.error || '操作失败'); + return data; + } + function toast(msg, type = '') { + let w = document.getElementById('toast-wrap'); + if (!w) { w = document.createElement('div'); w.id = 'toast-wrap'; document.body.appendChild(w); } + const t = document.createElement('div'); + t.className = 'toast ' + type; + t.textContent = msg; + w.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .4s'; setTimeout(() => t.remove(), 400); }, 2600); + } + function fmtTime(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; + return (h ? String(h).padStart(2, '0') + ':' : '') + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); + } + function fmtSize(b) { + if (!b) return '0 B'; + if (b < 1024) return b + ' B'; + if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; + return (b / 1048576).toFixed(1) + ' MB'; + } + function uid() { return 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + return { api, toast, fmtTime, fmtSize, uid, esc }; +})(); diff --git a/static/js/editor.js b/static/js/editor.js new file mode 100644 index 0000000..1560fee --- /dev/null +++ b/static/js/editor.js @@ -0,0 +1,735 @@ +// FreeCut 编辑器主逻辑 +(() => { + let project = { name: '未命名项目', data: { width: 1080, height: 1920, fps: 30, clips: [], transitions: [], bgm: null } }; + let projId = null; + let selectedId = null; + let pxPerSec = 80; + let assets = []; // 上传的素材库 + let quota = null; + let saveTimer = null; + let exporting = false; + + const $ = s => document.querySelector(s); + const $$ = s => Array.from(document.querySelectorAll(s)); + + const renderer = new Renderer($('#preview')); + renderer.onTime = t => { + $('#seekbar').value = t; + $('#cur-time').textContent = FC.fmtTime(t); + updatePlayhead(t); + }; + + // ---------------- 初始化 ---------------- + async function init() { + try { + const me = await FC.api('/api/me'); + quota = me.quota; + renderQuotaBadge(); + } catch (e) { FC.toast('未登录', 'err'); location.href = '/'; return; } + + const params = new URLSearchParams(location.search); + projId = parseInt(params.get('id')); + if (projId) { + try { + const r = await FC.api('/api/projects/' + projId); + project.name = r.project.name; + project.data = Object.assign(project.data, r.project.data || {}); + $('#projname').value = project.name; + } catch (e) { FC.toast('加载项目失败', 'err'); } + } + // 兜底默认宽高 + if (!project.data.width) { project.data.width = 1080; project.data.height = 1920; project.data.fps = 30; } + applyRatio(); + renderer.setProject(project.data); + try { + const r = await FC.api('/api/assets'); + assets = r.assets || []; + } catch (e) {} + renderAssets(); + renderTimeline(); + renderProps(); + bindEvents(); + } + + function renderQuotaBadge() { + const el = $('#quota-badge'); + if (quota) { + el.innerHTML = `${FC.esc(quota.tier_name)} 今日导出 ${quota.exports_used}/${quota.exports_per_day === 0 ? '∞' : quota.exports_per_day}`; + } + } + + // ---------------- 画布比例 ---------------- + function applyRatio() { + const d = project.data; + $('#preview').style.aspectRatio = `${d.width}/${d.height}`; + $$('.ratio-toggle button').forEach(b => b.classList.toggle('active', b.dataset.w == d.width)); + // 预览最大尺寸自适应 + const w = Math.min(d.width, 560); + const scale = w / d.width; + $('#preview').style.width = d.width * scale + 'px'; + $('#preview').style.height = d.height * scale + 'px'; + // 旧的坐标按比例缩放 + for (const c of d.clips) { + if (typeof c.x === 'number') c.x = Math.round(c.x * scale); + if (typeof c.y === 'number') c.y = Math.round(c.y * scale); + } + renderer.setProject(d); + } + + function setRatio(w, h) { + const d = project.data; + const scW = w / d.width, scH = h / d.height; + d.width = w; d.height = h; + for (const c of d.clips) { + if (typeof c.x === 'number') c.x = Math.round(c.x * scW); + if (typeof c.y === 'number') c.y = Math.round(c.y * scH); + if (c.w) c.w = Math.round(c.w * scW); + if (c.h) c.h = Math.round(c.h * scH); + } + applyRatio(); + scheduleSave(); + } + + // ---------------- 素材面板 ---------------- + async function uploadFiles(files) { + for (const f of files) { + const kind = f.type.startsWith('video') ? 'video' : f.type.startsWith('image') ? 'image' : f.type.startsWith('audio') ? 'audio' : 'video'; + if (kind === 'video' && !/video\//.test(f.type)) { FC.toast('不支持的视频格式:' + f.name, 'err'); continue; } + const fd = new FormData(); + fd.append('file', f); + fd.append('kind', kind); + try { + const r = await FC.api('/api/assets', { method: 'POST', body: fd }); + assets.push(r.asset); + FC.toast('已添加素材:' + f.name, 'ok'); + renderAssets(); + } catch (e) { FC.toast('上传失败:' + e.message, 'err'); } + } + } + + function renderAssets() { + const grid = $('#asset-grid'); + grid.innerHTML = ''; + for (const a of assets) { + const url = a.url || a.path || ''; + const div = document.createElement('div'); + div.className = 'asset-item'; + div.dataset.id = a.id; + div.title = a.name; + if (a.kind === 'video') { + div.innerHTML = `
🎬 ${FC.esc(a.name)}
`; + } else if (a.kind === 'image') { + div.innerHTML = `
🖼 ${FC.esc(a.name)}
`; + } else { + div.innerHTML = `
🎵
🎵 ${FC.esc(a.name)}
`; + } + div.addEventListener('click', () => addAssetToTimeline(a)); + grid.appendChild(div); + } + } + + function renderMusic() { + const el = $('#music-list'); + el.innerHTML = ''; + for (const m of MusicLibrary) { + const div = document.createElement('div'); + div.className = 'music-item'; + div.innerHTML = `
${m.icon} ${FC.esc(m.name)}${project.data.bgm && project.data.bgm.key === m.key ? ' ✅' : ''}
${FC.esc(m.desc)}
`; + div.addEventListener('click', () => { + if (project.data.bgm && project.data.bgm.key === m.key) { + // 再点一次移除配乐 + project.data.bgm = null; + project.data.clips = project.data.clips.filter(c => c.assetType !== 'bgm'); + FC.toast('已移除配乐'); + } else { + project.data.bgm = { key: m.key, volume: 0.6 }; + // 更新/添加 bgm clip + let bgmClip = project.data.clips.find(c => c.assetType === 'bgm'); + if (!bgmClip) { + bgmClip = { id: FC.uid(), track: 'audio', assetType: 'bgm', name: '配乐·' + m.name, start: 0, duration: Math.max(10, renderer.duration), volume: 0.6 }; + project.data.clips.push(bgmClip); + } + bgmClip.name = '配乐·' + m.name; + FC.toast('已添加配乐:' + m.name, 'ok'); + } + renderer.setProject(project.data); + renderMusic(); + renderTimeline(); + renderProps(); + scheduleSave(); + }); + el.appendChild(div); + } + } + + function renderFilters() { + const el = $('#filter-list'); + el.innerHTML = ''; + for (const [key, f] of Object.entries(Filters.presets)) { + const div = document.createElement('div'); + div.className = 'filter-item'; + div.innerHTML = `
${FC.esc(f.name)}`; + div.addEventListener('click', () => { + if (!selectedId) { FC.toast('请先在时间线选择一个视频片段', 'err'); return; } + const c = project.data.clips.find(x => x.id === selectedId); + if (!c) return; + c.filter = key; + renderTimeline(); renderProps(); scheduleSave(); + }); + el.appendChild(div); + } + } + + function renderTrans() { + const el = $('#trans-list'); + el.innerHTML = ''; + for (const tr of Transitions) { + const div = document.createElement('div'); + div.className = 'trans-item'; + div.textContent = tr.name; + div.addEventListener('click', () => { + const vids = project.data.clips.filter(c => c.track === 'video').sort((a, b) => a.start - b.start); + // 找到当前选中片段后面的相邻片段 + let a = vids.find(c => c.id === selectedId); + if (!a && vids.length) a = vids[0]; + const idx = vids.indexOf(a); + const b = vids[idx + 1]; + if (!b) { FC.toast('需要至少两个相邻视频片段', 'err'); return; } + if (tr.key === 'none') { + project.data.transitions = (project.data.transitions || []).filter(x => !(x.a === a.id && x.b === b.id)); + } else { + // 自动对齐:让 a 的结束 = b 的开始 + a.duration = b.start - a.start; + const existing = (project.data.transitions || []).filter(x => !(x.a === a.id && x.b === b.id)); + existing.push({ a: a.id, b: b.id, type: tr.key, dur: Math.min(0.5, a.duration) }); + project.data.transitions = existing; + } + renderTimeline(); renderProps(); scheduleSave(); + FC.toast(`已应用转场「${tr.name}」`, 'ok'); + }); + el.appendChild(div); + } + } + + // ---------------- 添加片段 ---------------- + async function addAssetToTimeline(a) { + const d = project.data; + const t = renderer.t; + const url = a.url || a.path || ''; + if (a.kind === 'video') { + const dur = await mediaDuration(url, 'video'); + d.clips.push({ id: FC.uid(), track: 'video', assetType: 'video', name: a.name, assetUrl: url, + start: Math.round(t * 20) / 20, duration: Math.max(1, dur), trimStart: 0, volume: 1, filter: 'none', w: d.width, h: d.height, x: 0, y: 0 }); + selectedId = d.clips[d.clips.length - 1].id; + } else if (a.kind === 'image') { + d.clips.push({ id: FC.uid(), track: 'video', assetType: 'image', name: a.name, assetUrl: url, + start: Math.round(t * 20) / 20, duration: 3, volume: 1, filter: 'none', w: d.width, h: d.height, x: 0, y: 0 }); + selectedId = d.clips[d.clips.length - 1].id; + } else if (a.kind === 'audio') { + const dur = await mediaDuration(url, 'audio'); + d.clips.push({ id: FC.uid(), track: 'audio', assetType: 'audio', name: a.name, assetUrl: url, + start: Math.round(t * 20) / 20, duration: Math.max(1, dur), trimStart: 0, volume: 1 }); + selectedId = d.clips[d.clips.length - 1].id; + } + renderer.setProject(d); + renderTimeline(); renderProps(); + scheduleSave(); + } + + function addTextClip() { + const d = project.data; + const t = renderer.t; + const c = { id: FC.uid(), track: 'text', assetType: 'text', name: '文字', text: '输入你的文字', + start: Math.round(t * 20) / 20, duration: 3, color: '#ffffff', bg: 'rgba(0,0,0,0.45)', + fontSize: 64, x: d.width / 2, y: d.height * 0.3, anim: 'fade' }; + d.clips.push(c); + selectedId = c.id; + renderer.setProject(d); + renderTimeline(); renderProps(); + switchTab('text-tab'); + FC.toast('文字已添加,在右侧编辑内容', 'ok'); + } + + function mediaDuration(url, kind) { + return new Promise(res => { + const el = kind === 'video' ? document.createElement('video') : document.createElement('audio'); + el.preload = 'metadata'; + el.onloadedmetadata = () => res(el.duration || 3); + el.onerror = () => res(3); + el.src = url; + setTimeout(() => res(el.duration || 3), 4000); + }); + } + + // ---------------- 时间线 ---------------- + function t2x(t) { return 64 + t * pxPerSec; } + function x2t(x) { return (x - 64) / pxPerSec; } + + function updateDur() { + $('#seekbar').max = renderer.duration; + $('#tot-time').textContent = FC.fmtTime(renderer.duration); + } + + function renderTimeline() { + updateDur(); + const d = project.data; + const inner = $('.tl-inner'); + const tracks = ['video', 'audio', 'text']; + const maxT = Math.max(5, renderer.duration); + const totalW = t2x(maxT) + 80; + inner.querySelector('.tl-ruler').innerHTML = ''; + const ruler = inner.querySelector('.tl-ruler'); + ruler.style.width = totalW + 'px'; + // 刻度 + const step = pxPerSec >= 60 ? 1 : 0.5; + for (let t = 0; t <= maxT + step; t += step) { + const d0 = document.createElement('div'); + d0.className = 'ruler-tick'; + d0.style.cssText = `position:absolute;left:${t2x(t)}px;top:0;bottom:0;width:1px;background:rgba(255,255,255,.08)`; + const lbl = document.createElement('span'); + lbl.textContent = t.toFixed(step < 1 ? 1 : 0); + lbl.style.cssText = 'position:absolute;left:' + (t2x(t) + 3) + 'px;top:2px;font-size:10px;color:var(--muted)'; + ruler.appendChild(d0); ruler.appendChild(lbl); + if (t % 5 === 0) d0.style.background = 'rgba(255,255,255,.2)'; + } + // 轨道容器 + const tl = inner.querySelector('.tl-tracks'); + tl.innerHTML = ''; + tl.style.width = totalW + 'px'; + tl.style.height = tracks.length * 48 + 'px'; + for (const tk of tracks) { + const row = document.createElement('div'); + row.className = 'tl-track'; + row.innerHTML = `
${tk === 'video' ? '🎬视频' : tk === 'audio' ? '🎵音频' : '📝文字'}
`; + const clips = d.clips.filter(c => c.track === tk).sort((a, b) => a.start - b.start); + for (const c of clips) { + const div = document.createElement('div'); + div.className = 'tl-clip ' + (c.track === 'video' ? 'video' : c.track === 'audio' ? 'audio' : 'text'); + if (c.id === selectedId) div.classList.add('selected'); + div.style.left = t2x(c.start) + 'px'; + div.style.width = Math.max(10, c.duration * pxPerSec) + 'px'; + div.innerHTML = `${iconOf(c)} ${FC.esc(shortName(c))}`; + if (c.assetType !== 'bgm') { + div.innerHTML += `
`; + } + bindClipEvents(div, c); + row.appendChild(div); + } + tl.appendChild(row); + } + updatePlayhead(renderer.t); + } + + function iconOf(c) { + if (c.assetType === 'bgm') return '🎼'; + if (c.assetType === 'text') return '🔤'; + if (c.assetType === 'image') return '🖼'; + if (c.assetType === 'video') return '🎬'; + return '🎵'; + } + function shortName(c) { + let n = c.name || c.text || ''; + if (n.length > 12) n = n.slice(0, 12) + '…'; + return n; + } + + function updatePlayhead(t) { + const ph = $('.tl-playhead'); + ph.style.left = t2x(t) + 'px'; + const inner = $('.tl-inner'); + // 自动跟随 + const lx = t2x(t); + if (lx > inner.scrollLeft + inner.clientWidth - 40) inner.scrollLeft = lx - inner.clientWidth + 40; + } + + function bindClipEvents(div, c) { + div.addEventListener('click', e => { + e.stopPropagation(); + selectedId = c.id; + renderTimeline(); renderProps(); + }); + // 拖动移动 + let drag = null; + div.addEventListener('pointerdown', e => { + if (e.target.classList.contains('trim')) return; + if (e.button !== 0 && e.pointerType === 'mouse') return; + drag = { mode: 'move', x0: e.clientX, start: c.start, moved: false }; + div.setPointerCapture(e.pointerId); + }); + div.addEventListener('pointermove', e => { + if (!drag || drag.mode !== 'move') return; + const dx = (e.clientX - drag.x0) / pxPerSec; + if (Math.abs(dx) > 0.02) drag.moved = true; + const ns = Math.max(0, Math.round((drag.start + dx) * 20) / 20); + c.start = ns; + renderTimeline(); + }); + div.addEventListener('pointerup', e => { + if (drag && drag.moved) scheduleSave(); + drag = null; + }); + // 修剪 + div.querySelectorAll('.trim').forEach(h => { + h.addEventListener('pointerdown', e => { + e.stopPropagation(); + drag = { mode: h.dataset.trim, x0: e.clientX, start: c.start, dur: c.duration, trim: c.trimStart || 0 }; + h.setPointerCapture(e.pointerId); + }); + h.addEventListener('pointermove', e => { + if (!drag || drag.mode !== h.dataset.trim) return; + const dt = Math.round(((e.clientX - drag.x0) / pxPerSec) * 20) / 20; + if (drag.mode === 'l') { + const newStart = Math.max(0, drag.start + dt); + const shift = newStart - drag.start; + const newTrim = Math.max(0, drag.trim + shift); + const realShift = newTrim - drag.trim; // 实际移动量(受素材开头限制) + c.start = Math.round((drag.start + realShift) * 20) / 20; + c.trimStart = Math.round(newTrim * 20) / 20; + c.duration = Math.max(0.2, Math.round((drag.dur - realShift) * 20) / 20); + } else { + c.duration = Math.max(0.2, Math.round((drag.dur + dt) * 20) / 20); + } + renderTimeline(); + }); + h.addEventListener('pointerup', () => { if (drag) { drag = null; renderProps(); scheduleSave(); } }); + }); + } + + function splitClip() { + if (!selectedId) { FC.toast('请选择片段', 'err'); return; } + const d = project.data; + const c = d.clips.find(x => x.id === selectedId); + const t = renderer.t; + if (t <= c.start + 0.05 || t >= c.start + c.duration - 0.05) { FC.toast('请把播放头移到片段中间再分割', 'err'); return; } + const lt = t - c.start; + const a = { ...c, id: FC.uid(), duration: Math.round(lt * 20) / 20 }; + const b = { ...c, id: FC.uid(), start: Math.round(t * 20) / 20, duration: Math.round((c.duration - lt) * 20) / 20, trimStart: Math.round((c.trimStart || 0) + lt * 20) / 20 }; + d.clips = d.clips.filter(x => x.id !== c.id); + d.clips.push(a, b); + selectedId = b.id; + renderer.setProject(d); + renderTimeline(); renderProps(); scheduleSave(); + FC.toast('已分割', 'ok'); + } + + function deleteClip() { + if (!selectedId) return; + const d = project.data; + d.clips = d.clips.filter(x => x.id !== selectedId); + d.transitions = (d.transitions || []).filter(x => x.a !== selectedId && x.b !== selectedId); + selectedId = null; + renderer.setProject(d); + renderTimeline(); renderProps(); scheduleSave(); + } + + // ---------------- 属性面板 ---------------- + function renderProps() { + const d = project.data; + const c = d.clips.find(x => x.id === selectedId); + const box = $('#props'); + if (!c) { + box.innerHTML = ` +

画布

+
+ + + +
+
+ +
+
${FC.fmtTime(renderer.duration)}
+
+

帮助

+
+ 1️⃣ 左侧「素材」上传或点击添加片段
+ 2️⃣ 时间线点选片段,拖动/两端修剪
+ 3️⃣ 「特效」「转场」「配乐」一键美化
+ 4️⃣ 点文字片段可在右侧改内容
+ 5️⃣ 右上角「导出」下载成品 +
+
`; + box.querySelectorAll('[data-ratio]').forEach(b => b.addEventListener('click', () => { + const r = b.dataset.ratio.split(':').map(Number); + setRatio(r[0] * 60, r[1] * 60); + })); + box.querySelectorAll('[data-fps]').forEach(b => b.addEventListener('click', () => { + d.fps = parseInt(b.dataset.fps); renderProps(); scheduleSave(); + })); + return; + } + // 视频/图片片段 + if (c.assetType === 'video' || c.assetType === 'image') { + box.innerHTML = ` +

片段

+
+
+
+
+
`; + const seg = box.querySelector('.filter-seg'); + Object.entries(Filters.presets).forEach(([k, f]) => { + const b = document.createElement('button'); + b.textContent = f.name; + b.classList.toggle('active', (c.filter || 'none') === k); + b.addEventListener('click', () => { c.filter = k; renderProps(); scheduleSave(); }); + seg.appendChild(b); + }); + box.querySelector('.vol').addEventListener('input', e => { + c.volume = parseFloat(e.target.value); + box.querySelector('.v-val').textContent = Math.round(c.volume * 100) + '%'; + scheduleSave(); + }); + } else if (c.assetType === 'text') { + box.innerHTML = ` +

文字

+
+
+
+
+
+
+
`; + box.querySelector('.txt').addEventListener('input', e => { c.text = e.target.value; scheduleSave(); }); + box.querySelector('.fs').addEventListener('input', e => { c.fontSize = parseInt(e.target.value); box.querySelector('.fs-val').textContent = c.fontSize; scheduleSave(); }); + box.querySelector('.clr').addEventListener('input', e => { c.color = e.target.value; scheduleSave(); }); + box.querySelector('.bgc').addEventListener('input', e => { + const v = e.target.value + 'cc'; + if (box.querySelector('.bgon').checked) c.bg = hexToRgba(v); + scheduleSave(); + }); + box.querySelector('.bgon').addEventListener('change', e => { c.bg = e.target.checked ? hexToRgba(box.querySelector('.bgc').value + 'cc') : ''; scheduleSave(); }); + const animSeg = box.querySelector('.anim-seg'); + for (const [k, n] of [['none', '静止'], ['fade', '淡入'], ['slide', '上滑'], ['zoom', '放大']]) { + const b = document.createElement('button'); + b.textContent = n; + b.classList.toggle('active', (c.anim || 'none') === k); + b.addEventListener('click', () => { c.anim = k; renderProps(); scheduleSave(); }); + animSeg.appendChild(b); + } + box.querySelector('.posy').addEventListener('input', e => { c.y = Math.round(project.data.height * e.target.value / 100); scheduleSave(); }); + } else { + box.innerHTML = `

音频

+
`; + box.querySelector('.vol').addEventListener('input', e => { + c.volume = parseFloat(e.target.value); + box.querySelector('.v-val').textContent = Math.round(c.volume * 100) + '%'; + if (renderer.els[c.id] && renderer.els[c.id].elem) renderer.els[c.id].elem.volume = c.volume; + scheduleSave(); + }); + } + // 公共操作 + box.innerHTML += `

操作

+
+ + + +
`; + $('#dup-clip').addEventListener('click', () => { duplicateClip(c); }); + $('#split-clip').addEventListener('click', splitClip); + $('#del-clip').addEventListener('click', deleteClip); + } + + function duplicateClip(c) { + const d = project.data; + const copy = { ...c, id: FC.uid(), start: Math.round((c.start + c.duration + 0.1) * 20) / 20 }; + d.clips.push(copy); + selectedId = copy.id; + renderer.setProject(d); + renderTimeline(); renderProps(); scheduleSave(); + } + + function hexOf(rgba) { + const m = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!m) return '#000000'; + const h = n => n.toString(16).padStart(2, '0'); + return '#' + h(+m[1]) + h(+m[2]) + h(+m[3]); + } + function hexToRgba(hex) { + const m = hex.replace('#', ''); + const r = parseInt(m.slice(0, 2), 16), g = parseInt(m.slice(2, 4), 16), b = parseInt(m.slice(4, 6), 16); + const a = m.length === 8 ? parseInt(m.slice(6, 8), 16) / 255 : 1; + return `rgba(${r},${g},${b},${a.toFixed(2)})`; + } + + // ---------------- 播放控制 ---------------- + function togglePlay() { + if (exporting) return; + if (renderer.playing) { renderer.pause(); $('#btn-play').textContent = '▶'; } + else { + if (renderer.t >= renderer.duration - 0.05) renderer.seek(0); + renderer.play(); + $('#btn-play').textContent = '⏸'; + } + } + + function switchTab(name) { + $$('.tabs .tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name)); + $$('.tab-body').forEach(b => b.classList.toggle('hidden', b.id !== name)); + } + + // ---------------- 保存 ---------------- + function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = setTimeout(save, 1200); + } + async function save() { + if (!projId) { + try { + const r = await FC.api('/api/projects', { method: 'POST', body: { name: project.name } }); + projId = r.id; + history.replaceState(null, '', '/editor?id=' + projId); + } catch (e) { return; } + } + // 缩略图 + let thumb = project.data.thumb || ''; + try { + const big = document.createElement('canvas'); + const rr = new Renderer(big); + rr.setProject(project.data); + rr.seek(0.1); + const c = document.createElement('canvas'); + c.width = 360; c.height = 640; + c.getContext('2d').drawImage(big, 0, 0, 360, 640); + thumb = c.toDataURL('image/jpeg', 0.6); + } catch (e) {} + try { + await FC.api('/api/projects/' + projId, { method: 'PUT', body: { name: project.name, data: project.data, thumb } }); + } catch (e) { FC.toast('保存失败:' + e.message, 'err'); } + } + + // ---------------- 导出 ---------------- + async function openExport() { + if (exporting) return; + if (!project.data.clips.length) { FC.toast('时间线还没有内容', 'err'); return; } + try { + const r = await FC.api('/api/quota/check-export', { method: 'POST', body: {} }); + quota = r.quota; + renderQuotaBadge(); + } catch (e) { + quota = (await FC.api('/api/quota')).quota; + renderQuotaBadge(); + FC.toast(e.message, 'err'); + return; + } + const d = project.data; + $('#ex-w').textContent = d.width; + $('#ex-h').textContent = d.height; + $('#ex-fps').value = d.fps || 30; + $('#ex-info').textContent = `分辨率 ${d.width}×${d.height} · 时长 ${renderer.duration.toFixed(1)}s · 剩余额度 ${quota.exports_per_day === 0 ? '不限' : quota.exports_left} 次`; + $('#export-modal').classList.remove('hidden'); + } + + async function startExport() { + if (exporting) return; + exporting = true; + // 用户手势内先唤醒音频,保证导出音频轨可用 + try { await Promise.race([renderer.ac.resume(), new Promise(r => setTimeout(r, 800))]); } catch (e) {} + $('#export-modal').classList.add('hidden'); + const wrap = $('#export-progress'); + wrap.classList.remove('hidden'); + $('#ex-progress-bar').style.width = '0%'; + $('#ex-progress-text').textContent = '准备素材中…'; + const exCancel = $('#ex-cancel'); + if (exCancel) exCancel.classList.add('hidden'); + renderer.onExport = p => { + $('#ex-progress-bar').style.width = (p * 100) + '%'; + $('#ex-progress-text').textContent = `正在渲染导出 ${(p * 100).toFixed(0)}%(实时编码中,请保持页面打开)`; + }; + try { + const r = await renderer.exportVideo({ fps: parseInt($('#ex-fps').value) || 30 }); + if (!r.ok) { FC.toast(r.error, 'err'); $('#ex-progress-text').textContent = r.error; return; } + // 下载 + const a = document.createElement('a'); + a.href = URL.createObjectURL(r.blob); + a.download = (project.name || '我的视频') + '.webm'; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 5000); + // 记录导出 + try { + await FC.api('/api/quota/record-export', { method: 'POST', body: { project_id: projId || 0, duration: Math.round(r.duration), size: r.blob.size } }); + quota = (await FC.api('/api/quota')).quota; + renderQuotaBadge(); + } catch (e) {} + $('#ex-progress-text').textContent = `✅ 导出完成!${r.duration.toFixed(1)}s · ${FC.fmtSize(r.blob.size)}`; + FC.toast('视频已导出 🎉', 'ok'); + await save(); + } catch (e) { + $('#ex-progress-text').textContent = '导出失败:' + e.message; + FC.toast('导出失败:' + e.message, 'err'); + } + renderer.onExport = null; + exporting = false; + if (exCancel) exCancel.classList.remove('hidden'); + setTimeout(() => wrap.classList.add('hidden'), 6000); + } + + // ---------------- 事件绑定 ---------------- + function bindEvents() { + // tabs + $$('.tabs .tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab))); + // 素材上传 + const drop = $('#upload-drop'); + const input = $('#file-input'); + input.addEventListener('change', e => { uploadFiles(e.target.files); input.value = ''; }); + drop.addEventListener('click', () => input.click()); + drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; }); + drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; }); + drop.addEventListener('drop', e => { e.preventDefault(); drop.style.borderColor = ''; uploadFiles(e.dataTransfer.files); }); + // 播放 + $('#btn-play').addEventListener('click', togglePlay); + $('#seekbar').addEventListener('input', e => { if (!renderer.playing) { renderer.seek(parseFloat(e.target.value)); } }); + // 时间线点击空白/刻度 seek + const inner = $('.tl-inner'); + inner.querySelector('.tl-ruler').addEventListener('pointerdown', e => { + const rect = inner.getBoundingClientRect(); + const t = Math.max(0, (e.clientX - rect.left + inner.scrollLeft - 64) / pxPerSec); + renderer.seek(t); + }); + // 缩放 + $('#zoom-in').addEventListener('click', () => { pxPerSec = Math.min(200, pxPerSec + 20); renderTimeline(); }); + $('#zoom-out').addEventListener('click', () => { pxPerSec = Math.max(40, pxPerSec - 20); renderTimeline(); }); + // 添加文字 + $('#add-text').addEventListener('click', addTextClip); + // 分割/删除 快捷键 + $('#btn-split').addEventListener('click', splitClip); + $('#btn-del').addEventListener('click', deleteClip); + document.addEventListener('keydown', e => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteClip(); } + else if (e.key === ' ') { e.preventDefault(); togglePlay(); } + else if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); FC.toast('已保存'); } + }); + // 项目名 + $('#projname').addEventListener('input', e => { project.name = e.target.value; scheduleSave(); }); + // 返回 + $('#btn-back').addEventListener('click', () => { save(); setTimeout(() => location.href = '/', 300); }); + // 导出 + $('#btn-export').addEventListener('click', openExport); + $('#ex-close').addEventListener('click', () => $('#export-modal').classList.add('hidden')); + $('#ex-start').addEventListener('click', startExport); + // 比例切换 + $$('.ratio-toggle button').forEach(b => b.addEventListener('click', () => { + const [w, h] = b.dataset.r.split('x').map(Number); + setRatio(w, h); + })); + // 窗口尺寸变化重排预览 + window.addEventListener('resize', () => { applyRatio(); }); + // 预览单击=播放/暂停 + $('#preview').addEventListener('click', togglePlay); + // 首次用户交互即唤醒音频(Autoplay 策略) + const wakeAudio = () => { renderer.ac.resume().catch(() => {}); document.removeEventListener('pointerdown', wakeAudio); document.removeEventListener('keydown', wakeAudio); }; + document.addEventListener('pointerdown', wakeAudio); + document.addEventListener('keydown', wakeAudio); + // 未保存提醒 + window.addEventListener('beforeunload', e => { + if (saveTimer) { save(); } + }); + } + + // 渲染配乐/特效/转场列表 + document.addEventListener('DOMContentLoaded', () => { + renderMusic(); renderFilters(); renderTrans(); + init(); + }); +})(); diff --git a/static/js/music.js b/static/js/music.js new file mode 100644 index 0000000..a3c097d --- /dev/null +++ b/static/js/music.js @@ -0,0 +1,203 @@ +// FreeCut 程序化配乐生成器 —— 全部在浏览器端合成,无版权风险,人人免费使用 +// 每种情绪生成一段可循环的纯音乐,作为内置配乐库 +const MusicEngine = (() => { + const cache = {}; + let _ctx = null; + function ctx() { + if (!_ctx) _ctx = new (window.AudioContext || window.webkitAudioContext)(); + return _ctx; + } + + // 确定性伪随机(保证同一情绪每次生成一样,可循环) + function makeRng(seed) { + let s = seed >>> 0; + return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; + } + + // 定义:BPM / 根音(midi) / 调式 / 和弦进行(每小节1个和弦根音, 类型) / 琶音间隔 / 节奏感 + const MOODS = { + warm: { name: '温馨治愈', bpm: 74, root: 60, scale: 'major', prog: [[60,'maj7'],[65,'maj7'],[67,'maj7'],[62,'maj7']], arp: 'full', feel: 'pad' }, + fresh: { name: '清新活力', bpm: 100, root: 62, scale: 'major', prog: [[62,'maj7'],[60,'maj7'],[67,'maj7'],[65,'maj7']], arp: 'up', feel: 'pluck' }, + epic: { name: '激昂大片', bpm: 120, root: 57, scale: 'minor', prog: [[57,'min'],[55,'min'],[60,'min'],[55,'min']], arp: 'drive', feel: 'epic' }, + night: { name: '深夜思绪', bpm: 70, root: 57, scale: 'minor', prog: [[57,'min7'],[62,'maj7'],[55,'min7'],[60,'maj7']], arp: 'soft', feel: 'lofi' }, + tech: { name: '科技未来', bpm: 110, root: 64, scale: 'minor', prog: [[64,'min'],[61,'min'],[66,'min'],[59,'min']], arp: 'seq', feel: 'synth' }, + happy: { name: '欢乐节奏', bpm: 118, root: 60, scale: 'major', prog: [[60,'maj'],[65,'maj'],[60,'maj'],[67,'maj']], arp: 'bounce', feel: 'pop' }, + }; + + const MAJOR = [0, 2, 4, 5, 7, 9, 11, 12]; + const MINOR = [0, 2, 3, 5, 7, 8, 10, 12]; + const CHORD = { + 'maj': [0, 4, 7], 'maj7': [0, 4, 7, 11], 'min': [0, 3, 7], + 'min7': [0, 3, 7, 10], 'sus': [0, 5, 7], 'dim': [0, 3, 6], + }; + + function scaleOf(mood) { return mood.scale === 'minor' ? MINOR : MAJOR; } + + function generate(key, bars = 8) { + const mood = MOODS[key]; + if (!mood) return null; + const ac = ctx(); + const rng = makeRng(hashStr(key)); + const spb = 60 / mood.bpm; // 每拍秒数 + const secsPerBar = spb * 4; + const total = secsPerBar * bars; + const sr = ac.sampleRate; + const buf = ac.createBuffer(2, Math.ceil(total * sr), sr); + const sc = scaleOf(mood); + const rootTone = mood.root; + + // 和弦根音序列 + const prog = mood.prog; + const chords = []; + for (let i = 0; i < bars; i++) { + const [r, type] = prog[i % prog.length]; + const tones = CHORD[type].map(iv => r + 12 + iv); // 中音区 + chords.push(tones); + } + + // 主旋律音阶池 + const pool = sc.map(iv => rootTone + 12 + iv).concat(sc.map(iv => rootTone + 24 + iv)); + const pool2 = sc.map(iv => rootTone + 12 + iv); + + // 旋律事件(每小节2-4个音) + const melody = []; + for (let b = 0; b < bars; b++) { + const n = mood.feel === 'epic' ? 2 : mood.feel === 'lofi' ? 2 : 3 + (rng() > 0.5 ? 1 : 0); + for (let i = 0; i < n; i++) { + const beatPos = (i / n) * 4; + const t = (b + beatPos / 4) * secsPerBar; + let note; + if (mood.feel === 'epic') { + note = chords[b][0] + 12; // 根音高八度,宏大 + } else if (mood.feel === 'lofi') { + note = pool[Math.floor(rng() * pool.length)]; // 慵懒随机 + } else { + note = pool2[Math.floor(rng() * pool2.length)]; + } + const dur = (spb * 4) / n * (0.75 + rng() * 0.4); + melody.push({ t, note, dur, vel: 0.5 + rng() * 0.3 }); + } + } + + // ---------- 渲染 ---------- + const L = buf.getChannelData(0), R = buf.getChannelData(1); + const N = L.length; + const add = (ch, from, dur, fn) => { + const s0 = Math.max(0, Math.floor(from * sr)); + const n = Math.min(N - s0, Math.ceil(dur * sr)); + for (let i = 0; i < n; i++) { + const t = i / sr; + const v = fn(t); + ch[s0 + i] += v; + } + }; + + // 底鼓/军鼓简单节奏(确定性) + for (let b = 0; b < bars; b++) { + const t0 = b * secsPerBar; + // 底鼓:每小节 4 拍 + for (let k = 0; k < 4; k++) { + const t = t0 + k * spb; + add(L, t, 0.25, tt => Math.sin(2 * Math.PI * (90 - tt * 160) * tt) * Math.exp(-tt * 14) * 0.5); + add(R, t, 0.25, tt => Math.sin(2 * Math.PI * (90 - tt * 160) * tt) * Math.exp(-tt * 14) * 0.5); + } + // 军鼓/拍手:2、4拍,激昂时用 + if (mood.feel === 'epic' || mood.feel === 'pop' || mood.feel === 'drive') { + for (const k of [1, 3]) { + const t = t0 + k * spb; + const noise = makeNoise(rng()); + add(L, t, 0.18, tt => noise(Math.floor(tt * 20000)) * Math.exp(-tt * 30) * 0.35); + add(R, t, 0.18, tt => noise(Math.floor(tt * 20000)) * Math.exp(-tt * 30) * 0.35); + } + } + } + + // 贝斯 + for (let b = 0; b < bars; b++) { + const chRoot = mood.prog[b % mood.prog.length][0]; + for (let k = 0; k < 4; k++) { + const t = b * secsPerBar + k * spb; + const f = midiToFreq(chRoot - 12); + add(L, t, spb * 0.9, tt => Math.sin(2 * Math.PI * f * tt) * Math.exp(-tt * 3.2) * 0.30); + add(R, t, spb * 0.9, tt => Math.sin(2 * Math.PI * f * tt) * Math.exp(-tt * 3.2) * 0.30); + } + } + + // 和弦铺底 + for (let b = 0; b < bars; b++) { + const tones = chords[b]; + const t = b * secsPerBar; + for (const n of tones) { + const f = midiToFreq(n); + add(L, t, secsPerBar, tt => detuneSaw(tt, f) * env(tt, secsPerBar, 0.8, 0.5) * 0.055); + add(R, t, secsPerBar, tt => detuneSaw(tt, f * 1.003) * env(tt, secsPerBar, 0.8, 0.5) * 0.055); + } + } + + // 主旋律 + for (const m of melody) { + const f = midiToFreq(m.note); + if (mood.feel === 'epic') { + add(L, m.t, m.dur, tt => detuneSaw(tt, f) * env(tt, m.dur, 0.15, 0.6) * m.vel * 0.22); + add(R, m.t, m.dur, tt => detuneSaw(tt, f * 1.002) * env(tt, m.dur, 0.15, 0.6) * m.vel * 0.22); + } else if (mood.feel === 'lofi') { + add(L, m.t, m.dur, tt => softTone(tt, f) * env(tt, m.dur, 0.2, 0.8) * m.vel * 0.35); + add(R, m.t, m.dur, tt => softTone(tt, f) * env(tt, m.dur, 0.2, 0.8) * m.vel * 0.35); + } else { + // 琶音拨弦 + add(L, m.t, m.dur, tt => pluck(tt, f) * m.vel * 0.30); + add(R, m.t, m.dur, tt => pluck(tt, f * 1.001) * m.vel * 0.30); + } + } + + // 归一化 + let peak = 0; + for (let i = 0; i < N; i++) peak = Math.max(peak, Math.abs(L[i]), Math.abs(R[i])); + if (peak > 0.95) { + const g = 0.95 / peak; + for (let i = 0; i < N; i++) { L[i] *= g; R[i] *= g; } + } + cache[key] = buf; + return buf; + } + + function hashStr(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h; } + function midiToFreq(m) { return 440 * Math.pow(2, (m - 69) / 12); } + function env(t, dur, a, r) { + const rel = Math.max(0.05, dur * r); + if (t < a) return t / a; + if (t > dur) return 0; + return Math.max(0, 1 - (t - a) / rel); + } + function detuneSaw(t, f) { + const p = (t * f) % 1; + return 2 * p - 1; + } + function softTone(t, f) { + const s = Math.sin(2 * Math.PI * f * t); + return 0.6 * s + 0.4 * Math.sin(2 * Math.PI * f * 2 * t) * 0.5; + } + function pluck(t, f) { + const d = Math.sin(2 * Math.PI * f * t) * Math.exp(-t * 6); + const h = Math.sin(2 * Math.PI * f * 2.01 * t) * Math.exp(-t * 16) * 0.4; + return d + h; + } + function makeNoise(rng) { + const buf = new Float32Array(20000); + for (let i = 0; i < buf.length; i++) buf[i] = rng() * 2 - 1; + return idx => buf[Math.abs(idx) % buf.length]; + } + + // 获取(生成并缓存)配乐 AudioBuffer + function getBuffer(key) { + if (cache[key]) return Promise.resolve(cache[key]); + // 稍等一帧,避免阻塞 + return new Promise(res => setTimeout(() => res(generate(key)), 30)); + } + + function list() { + return Object.entries(MOODS).map(([k, v]) => ({ key: k, name: v.name, bpm: v.bpm })); + } + + return { list, getBuffer, generate, ctx }; +})(); diff --git a/static/js/renderer.js b/static/js/renderer.js new file mode 100644 index 0000000..8184692 --- /dev/null +++ b/static/js/renderer.js @@ -0,0 +1,421 @@ +// FreeCut 渲染引擎:Canvas 合成 + 时间线播放 + 实时导出(WebM) +class Renderer { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.els = {}; // clipId -> {elem} + this.mediaEls = {}; // url -> HTMLMediaElement + this.imgEls = {}; // url -> Image + this.ac = MusicEngine.ctx(); + this.master = this.ac.createGain(); + this.master.gain.value = 1; + this.master.connect(this.ac.destination); + this.exportDest = null; + this.bgmGain = this.ac.createGain(); + this.bgmGain.connect(this.master); + this.playing = false; + this.t = 0; + this.raf = 0; + this.lastPerf = 0; + this.bgmSrc = null; + this.bgmStartedAt = 0; + this.onTime = null; + this.onExport = null; + this.exporting = false; + } + + setProject(proj) { + this.proj = proj || {}; + this.els = {}; + const p = this.proj; + this.canvas.width = p.width || 1080; + this.canvas.height = p.height || 1920; + this.mediaEls = {}; + this.imgEls = {}; + this.ensureMedia(); + } + + get duration() { + const p = this.proj; + let d = 0; + for (const c of p.clips || []) d = Math.max(d, (c.start || 0) + (c.duration || 0)); + return Math.max(0.2, d); + } + + get clips() { return (this.proj && this.proj.clips) || []; } + + ensureMedia() { + for (const c of this.clips) { + if (c.assetType === 'video' || c.assetType === 'audio') { + if (!this.mediaEls[c.assetUrl]) { + const v = document.createElement('video'); + v.crossOrigin = 'anonymous'; + v.preload = 'auto'; + v.playsInline = true; + v.muted = false; + v.src = c.assetUrl; + this.mediaEls[c.assetUrl] = v; + // 接入混音 + try { + const src = this.ac.createMediaElementSource(v); + const g = this.ac.createGain(); + g.gain.value = 1; + src.connect(g); g.connect(this.master); + v._srcNode = src; v._gain = g; + } catch (e) { /* 已接入过 */ } + } + this.els[c.id] = { elem: this.mediaEls[c.assetUrl] }; + } else if (c.assetType === 'image') { + if (!this.imgEls[c.assetUrl]) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.src = c.assetUrl; + this.imgEls[c.assetUrl] = img; + } + this.els[c.id] = { img: this.imgEls[c.assetUrl] }; + } else if (c.assetType === 'text') { + this.els[c.id] = { text: true }; + } else if (c.assetType === 'bgm') { + this.els[c.id] = { bgm: true }; + } + } + } + + // ---------- 播放控制 ---------- + async play() { + if (this.playing) return; + if (this.ac.state === 'suspended') await this.ac.resume(); + this.playing = true; + this.lastPerf = performance.now(); + this.startMedia(this.t); + const loop = () => { + if (!this.playing) return; + const now = performance.now(); + const dt = (now - this.lastPerf) / 1000; + this.lastPerf = now; + this.t += dt; + if (this.t >= this.duration) { + this.t = this.duration; + this.pause(); + if (this.onTime) this.onTime(this.t); + return; + } + this.render(); + this.syncMedia(this.t); + if (this.onTime) this.onTime(this.t); + this.raf = requestAnimationFrame(loop); + }; + this.raf = requestAnimationFrame(loop); + } + + pause() { + this.playing = false; + cancelAnimationFrame(this.raf); + this.stopMedia(); + if (this.onTime) this.onTime(this.t); + } + + seek(t) { + this.t = Math.max(0, Math.min(t, this.duration)); + if (this.playing) { this.pause(); } + this.render(); + if (this.onTime) this.onTime(this.t); + } + + // 把当前 t 对应的所有媒体元素对齐到正确位置 + startMedia(t) { + const ac = this.ac; + for (const c of this.clips) { + const e = this.els[c.id]; + if (!e) continue; + const s = c.start || 0, d = c.duration || 0; + if (c.assetType === 'video' && e.elem) { + if (t >= s && t < s + d) { + const at = (c.trimStart || 0) + (t - s); + try { e.elem.currentTime = at; e.elem.volume = c.volume ?? 1; } catch (err) {} + const p = e.elem.play(); + if (p) p.catch(() => {}); + } else { try { e.elem.pause(); } catch (err) {} } + } else if (c.assetType === 'audio' && e.elem) { + if (t >= s && t < s + d) { + const at = (c.trimStart || 0) + (t - s); + try { e.elem.currentTime = at; e.elem.volume = c.volume ?? 1; } catch (err) {} + const p = e.elem.play(); + if (p) p.catch(() => {}); + } else { try { e.elem.pause(); } catch (err) {} } + } + } + // 配乐 + this.startBgm(t); + } + + syncMedia(t) { + for (const c of this.clips) { + const e = this.els[c.id]; + if (!e || !e.elem) continue; + const s = c.start || 0, d = c.duration || 0; + const active = t >= s && t < s + d; + if ((c.assetType === 'video' || c.assetType === 'audio')) { + if (active) { + try { if (e.elem.paused) e.elem.play().catch(() => {}); } catch (err) {} + } else if (!e.elem.paused) { + try { e.elem.pause(); } catch (err) {} + } + } + } + // 配乐自动开始 + const bgm = this.proj.bgm; + if (bgm) { + const b = this.clips.find(x => x.assetType === 'bgm'); + if (b && t >= (b.start || 0) && !this.bgmSrc) this.startBgm(t); + } + } + + async startBgm(t) { + const b = this.clips.find(x => x.assetType === 'bgm'); + if (!b || !this.proj.bgm) return; + if (this.bgmSrc) return; + try { + const buf = await MusicEngine.getBuffer(this.proj.bgm.key); + const src = this.ac.createBufferSource(); + src.buffer = buf; + src.loop = true; + this.bgmGain.gain.value = (this.proj.bgm.volume ?? 0.6); + src.connect(this.bgmGain); + const offset = Math.max(0, (t - (b.start || 0)) % buf.duration); + src.start(0, offset); + this.bgmSrc = src; + this.bgmStartedAt = t; + } catch (e) { console.warn('bgm start fail', e); } + } + + stopMedia() { + for (const c of this.clips) { + const e = this.els[c.id]; + if (e && e.elem) { try { e.elem.pause(); } catch (err) {} } + } + if (this.bgmSrc) { try { this.bgmSrc.stop(); } catch (e) {} this.bgmSrc = null; } + } + + // ---------- 渲染一帧 ---------- + render() { + const ctx = this.ctx; + const W = this.canvas.width, H = this.canvas.height; + const t = this.t; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha = 1; + ctx.filter = 'none'; + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, W, H); + + // 视频层(按数组顺序叠加) + const vidClips = this.clips.filter(c => c.track === 'video' || c.assetType === 'video'); + const activeVids = vidClips.filter(c => t >= (c.start || 0) && t < (c.start || 0) + (c.duration || 0)); + + // 转场 + const trans = this.activeTransitions(t); + + for (const c of activeVids) { + const e = this.els[c.id]; + let alpha = 1; + // 参与转场:确定进出场 alpha + const tr = trans.find(x => x.b === c.id); + const trOut = trans.find(x => x.a === c.id); + if (tr) { + const p = tr.p; // 0..1 进场进度 + if (tr.type === 'fade') alpha = p; + else if (tr.type === 'slide') alpha = p; + else if (tr.type === 'zoom') alpha = p; + else alpha = 1; + } else if (trOut) { + const p = trOut.p; + if (trOut.type === 'fade') alpha = 1 - p; + else if (trOut.type === 'slide') alpha = 1 - p; + else if (trOut.type === 'zoom') alpha = 1 - p; + } + ctx.globalAlpha = alpha; + ctx.filter = c.filter ? Filters.toCss(c.filter) : 'none'; + let dx = 0, dy = 0, sc = 1; + if (tr && tr.type === 'slide') { dx = (1 - tr.p) * W; } + if (tr && tr.type === 'zoom') { sc = 0.92 + 0.08 * tr.p; } + if (trOut && trOut.type === 'slide') { dx = -trOut.p * W; } + if (trOut && trOut.type === 'zoom') { sc = 1 - 0.08 * trOut.p; } + + ctx.save(); + ctx.translate(dx, dy); + ctx.translate(W / 2, H / 2); ctx.scale(sc, sc); ctx.translate(-W / 2, -H / 2); + const w = c.w || W, h = c.h || H, x = c.x ?? 0, y = c.y ?? 0; + ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); + if (e && e.elem) { + // 视频:若媒体未就绪画最后一帧/黑色 + drawMedia(e.elem, ctx, x, y, w, h); + // 对齐到当前帧 + const at = (c.trimStart || 0) + (t - (c.start || 0)); + if (Math.abs(e.elem.currentTime - at) > 0.05 && e.elem.readyState >= 1) { + try { e.elem.currentTime = at; } catch (err) {} + } + } else if (e && e.img) { + drawMedia(e.img, ctx, x, y, w, h); + } + ctx.restore(); + } + + // 文本层 + ctx.globalAlpha = 1; + ctx.filter = 'none'; + const textClips = this.clips.filter(c => c.assetType === 'text' && t >= (c.start || 0) && t < (c.start || 0) + (c.duration || 0)); + for (const c of textClips) { + const lt = t - (c.start || 0); + drawText(c, ctx, W, H, lt); + } + ctx.globalAlpha = 1; + } + + activeTransitions(t) { + const out = []; + for (const tr of (this.proj.transitions || [])) { + const a = this.clips.find(x => x.id === tr.a); + const b = this.clips.find(x => x.id === tr.b); + if (!a || !b) continue; + const dur = tr.dur || 0.5; + const t0 = b.start; + if (t >= t0 && t <= t0 + dur) { + out.push({ ...tr, a, b, p: (t - t0) / dur }); + } + } + return out; + } + + // ---------- 导出 ---------- + async exportVideo(opts = {}) { + if (this.exporting) return { ok: false, error: '正在导出中' }; + this.exporting = true; + try { + const fps = opts.fps || this.proj.fps || 30; + await this.waitMediaReady(); + this.master.disconnect(this.exportDest); + this.exportDest = null; + return { ok: true, blob, mime, duration: dur, width: this.canvas.width, height: this.canvas.height }; + } catch (e) { + console.error('export error', e); + try { this.master.disconnect(this.exportDest); } catch (err) {} + this.exportDest = null; + this.exporting = false; + return { ok: false, error: '导出失败:' + e.message }; + } + } + + waitMediaReady(timeout = 15000) { + const need = this.clips.filter(c => c.assetType === 'video' || c.assetType === 'image'); + const t0 = Date.now(); + return new Promise((res) => { + const check = () => { + const ready = need.every(c => { + const e = this.els[c.id]; + if (!e) return true; + if (e.elem) return e.elem.readyState >= 2; + if (e.img) return e.img.complete && e.img.naturalWidth > 0; + return true; + }); + if (ready || Date.now() - t0 > timeout) res(); + else setTimeout(check, 150); + }; + check(); + }); + } + + dispose() { + this.pause(); + this.stopMedia(); + for (const k in this.mediaEls) { + try { this.mediaEls[k].pause(); this.mediaEls[k].src = ''; } catch (e) {} + } + } +} + +// ---------- 工具 ---------- +function drawMedia(media, ctx, x, y, w, h) { + if (!media) return; + let iw, ih; + if (media.videoWidth) { iw = media.videoWidth; ih = media.videoHeight; } + else if (media.naturalWidth) { iw = media.naturalWidth; ih = media.naturalHeight; } + else return; + if (!iw || !ih) return; + const scale = Math.max(w / iw, h / ih); + const dw = iw * scale, dh = ih * scale; + const dx = x + (w - dw) / 2, dy = y + (h - dh) / 2; + ctx.drawImage(media, dx, dy, dw, dh); +} + +function drawText(c, ctx, W, H, lt) { + const fs = (c.fontSize || 72) * Math.max(W / 1080, 1); + const x = c.x ?? W / 2, y = c.y ?? H * 0.3; + let alpha = 1, dy = 0, sc = 1; + if (c.anim === 'fade') alpha = Math.min(1, lt / 0.5) * Math.min(1, (c.duration - lt) / 0.4 + 0.2); + if (c.anim === 'slide') { dy = (1 - Math.min(1, lt / 0.6)) * 60; alpha = Math.min(1, lt / 0.6); } + if (c.anim === 'zoom') { sc = 0.6 + 0.4 * Math.min(1, lt / 0.5); alpha = Math.min(1, lt / 0.4); } + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(1, alpha)); + ctx.translate(x, y + dy); + ctx.scale(sc, sc); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `bold ${fs}px "PingFang SC","Microsoft YaHei",sans-serif`; + if (c.bg) { + const bw = ctx.measureText(c.text || '').width + fs * 1.4; + ctx.fillStyle = c.bg; + roundRect(ctx, -bw / 2, -fs * 0.9, bw, fs * 1.8, fs * 0.5); + ctx.fill(); + } + ctx.lineWidth = fs * 0.12; + ctx.strokeStyle = 'rgba(0,0,0,.6)'; + ctx.strokeText(c.text || '', 0, 0); + ctx.fillStyle = c.color || '#ffffff'; + ctx.fillText(c.text || '', 0, 0); + ctx.restore(); +} + +function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +// ---------- 滤镜预设 ---------- +const Filters = { + presets: { + none: { name: '原片', css: 'none', sw: 'linear-gradient(135deg,#aaa,#666)' }, + warm: { name: '暖阳', css: 'sepia(0.35) saturate(1.15) contrast(1.05)', sw: 'linear-gradient(135deg,#ff9a56,#ff5e62)' }, + cool: { name: '冷调', css: 'saturate(0.85) hue-rotate(12deg) brightness(1.02)', sw: 'linear-gradient(135deg,#4facfe,#00f2fe)' }, + bw: { name: '黑白', css: 'grayscale(1) contrast(1.1)', sw: 'linear-gradient(135deg,#333,#999)' }, + vintage: { name: '复古', css: 'sepia(0.5) contrast(0.95) brightness(0.95)', sw: 'linear-gradient(135deg,#c79081,#dfa579)' }, + fresh: { name: '清新', css: 'saturate(1.2) brightness(1.05) contrast(0.98)', sw: 'linear-gradient(135deg,#a1ffce,#faffd1)' }, + film: { name: '电影', css: 'contrast(1.15) saturate(1.1) brightness(0.94) sepia(0.12)', sw: 'linear-gradient(135deg,#373b44,#4286f4)' }, + fade: { name: '褪色', css: 'saturate(0.6) brightness(1.06) contrast(0.92)', sw: 'linear-gradient(135deg,#bdc3c7,#2c3e50)' }, + dream: { name: '梦幻', css: 'saturate(1.4) brightness(1.08) hue-rotate(-10deg)', sw: 'linear-gradient(135deg,#ff9a9e,#fecfef)' }, + cyber: { name: '赛博', css: 'contrast(1.25) saturate(1.5) hue-rotate(200deg) brightness(1.02)', sw: 'linear-gradient(135deg,#00f2fe,#4facfe)' }, + night: { name: '夜景', css: 'brightness(0.82) contrast(1.1) saturate(0.9) hue-rotate(200deg)', sw: 'linear-gradient(135deg,#0f2027,#2c5364)' }, + vivid: { name: '鲜明', css: 'saturate(1.6) contrast(1.08)', sw: 'linear-gradient(135deg,#f7971e,#ffd200)' }, + }, + toCss(name) { return (this.presets[name] || this.presets.none).css; } +}; + +const Transitions = [ + { key: 'none', name: '硬切' }, + { key: 'fade', name: '淡入淡出' }, + { key: 'slide', name: '滑动' }, + { key: 'zoom', name: '缩放' }, +]; + +const MusicLibrary = [ + { key: 'warm', name: '温馨治愈', icon: '🌷', desc: '76BPM · 适合生活/美食/日常' }, + { key: 'fresh', name: '清新活力', icon: '🌿', desc: '100BPM · 适合旅行/Vlog' }, + { key: 'epic', name: '激昂大片', icon: '🔥', desc: '120BPM · 适合预告/燃向' }, + { key: 'night', name: '深夜思绪', icon: '🌙', desc: '70BPM · 适合伤感/独白' }, + { key: 'tech', name: '科技未来', icon: '🤖', desc: '110BPM · 适合数码/演示' }, + { key: 'happy', name: '欢乐节奏', icon: '🎉', desc: '118BPM · 适合生日/聚会' }, +];