412 lines
16 KiB
Python
412 lines
16 KiB
Python
"""
|
|
图片生成蓝图:生成、历史、下载、拼接、版本
|
|
"""
|
|
import os
|
|
import sys
|
|
import json
|
|
import re
|
|
import io
|
|
import base64
|
|
import tempfile
|
|
import threading
|
|
import subprocess
|
|
import requests
|
|
from urllib.parse import urlparse, quote
|
|
|
|
from flask import Blueprint, request, jsonify, Response, session, send_file
|
|
from PIL import Image
|
|
|
|
from app_common import get_db, login_required, BASE_DIR
|
|
from config import STITCH_WORKFLOW_ID,client_name
|
|
|
|
image_bp = Blueprint('image', __name__)
|
|
|
|
|
|
def _load_image_from_url_or_data(url_or_data):
|
|
"""从 http(s) URL 或 data URL 加载为 PIL Image,失败返回 None"""
|
|
if not url_or_data or not isinstance(url_or_data, str):
|
|
return None
|
|
try:
|
|
if url_or_data.startswith('data:'):
|
|
m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL)
|
|
if not m:
|
|
return None
|
|
raw = base64.b64decode(m.group(1).strip())
|
|
img = Image.open(io.BytesIO(raw))
|
|
elif url_or_data.startswith(('http://', 'https://')):
|
|
resp = requests.get(url_or_data, timeout=15)
|
|
resp.raise_for_status()
|
|
img = Image.open(io.BytesIO(resp.content))
|
|
else:
|
|
return None
|
|
if img.mode != 'RGB':
|
|
img = img.convert('RGB')
|
|
return img
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _stitch_and_upload_long_image(urls):
|
|
"""将多张图片 URL 先上传获取 file_id,再调用 workflow_run 拼接长图,返回 data.merged_image_url;失败返回 None。"""
|
|
if not urls or not isinstance(urls, (list, tuple)):
|
|
return None
|
|
from coze import upload_file as coze_upload_file, workflow_run
|
|
file_ids = []
|
|
temp_paths = []
|
|
try:
|
|
for u in urls:
|
|
img = _load_image_from_url_or_data(u)
|
|
if img is None:
|
|
continue
|
|
fd, path = tempfile.mkstemp(suffix='.png')
|
|
try:
|
|
os.close(fd)
|
|
img.save(path)
|
|
temp_paths.append(path)
|
|
resp = coze_upload_file(path)
|
|
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
|
file_ids.append(resp['data']['id'])
|
|
except Exception:
|
|
pass
|
|
if not file_ids:
|
|
return None
|
|
parameters = {"images": [{"file_id": fid} for fid in file_ids]}
|
|
resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False)
|
|
if resp.get('code') != 0:
|
|
return None
|
|
data = resp.get('data') or {}
|
|
data = json.loads(data)
|
|
merged_image_url = data.get('merged_image_url')
|
|
if merged_image_url:
|
|
return merged_image_url
|
|
output_str = data.get('output') or ''
|
|
if output_str:
|
|
try:
|
|
outer = json.loads(output_str)
|
|
inner_str = outer.get('Output', '{}')
|
|
inner = json.loads(inner_str)
|
|
data_str = inner.get('data', '[]')
|
|
inner_data = json.loads(data_str)
|
|
merged_image_url = inner_data.get('merged_image_url')
|
|
return merged_image_url
|
|
except Exception:
|
|
pass
|
|
return None
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
for p in temp_paths:
|
|
try:
|
|
os.unlink(p)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _sanitize_params_for_history(params):
|
|
"""移除 base64 大字段及敏感字段,仅保留可存储的请求参数"""
|
|
exclude = ('ref_images', 'proc_images', 'layout_image')
|
|
out = {}
|
|
for k, v in (params or {}).items():
|
|
if k == 'api_key':
|
|
continue
|
|
if k in exclude:
|
|
if isinstance(v, list):
|
|
out[f'{k}_count'] = len(v)
|
|
else:
|
|
out[f'{k}_count'] = 1 if v else 0
|
|
elif isinstance(v, (str, int, float, bool, type(None))):
|
|
out[k] = v
|
|
elif isinstance(v, list) and not v:
|
|
out[k] = []
|
|
elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)):
|
|
out[k] = v
|
|
else:
|
|
out[k] = str(v)[:200] if v else None
|
|
return out
|
|
|
|
|
|
@image_bp.route('/api/generate', methods=['POST'])
|
|
@login_required
|
|
def api_generate():
|
|
"""生成图片:调用 generate_api,上传原图到 OSS,保存历史记录"""
|
|
try:
|
|
params = request.get_json() or {}
|
|
from generate_api import generate
|
|
result = generate(params)
|
|
if result.get('success') and result.get('urls'):
|
|
long_image_url = result.get("long_image_url")
|
|
result["long_image_url"] = long_image_url
|
|
import json as _json
|
|
history_id = None
|
|
try:
|
|
hid = params.get('history_id')
|
|
if hid is not None:
|
|
try:
|
|
hid = int(hid)
|
|
except (TypeError, ValueError):
|
|
hid = None
|
|
conn = get_db()
|
|
with conn.cursor() as cur:
|
|
if hid is not None and hid > 0:
|
|
cur.execute(
|
|
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
|
|
(hid, session['user_id']),
|
|
)
|
|
row = cur.fetchone()
|
|
existing_urls = []
|
|
if row and row.get('result_urls'):
|
|
try:
|
|
existing_urls = _json.loads(row['result_urls'])
|
|
except Exception:
|
|
existing_urls = []
|
|
new_urls = result.get('urls') or []
|
|
new_url = new_urls[0] if new_urls else None
|
|
idx = params.get('history_index', 0)
|
|
try:
|
|
idx = int(idx)
|
|
except (TypeError, ValueError):
|
|
idx = 0
|
|
if new_url:
|
|
if not isinstance(existing_urls, list):
|
|
existing_urls = []
|
|
while len(existing_urls) <= idx:
|
|
existing_urls.append(existing_urls[-1] if existing_urls else new_url)
|
|
existing_urls[idx] = new_url
|
|
merged_result_urls = existing_urls or new_urls
|
|
cur.execute(
|
|
"""UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s
|
|
WHERE id=%s AND user_id=%s""",
|
|
(
|
|
params.get('panel_type', ''),
|
|
_json.dumps(result.get('original_urls') or []),
|
|
_json.dumps(_sanitize_params_for_history(params)),
|
|
_json.dumps(merged_result_urls),
|
|
hid,
|
|
session['user_id'],
|
|
),
|
|
)
|
|
if cur.rowcount > 0:
|
|
history_id = hid
|
|
else:
|
|
cur.execute(
|
|
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
|
|
VALUES (%s, %s, %s, %s, %s, %s)""",
|
|
(
|
|
session['user_id'],
|
|
params.get('panel_type', ''),
|
|
_json.dumps(result.get('original_urls') or []),
|
|
_json.dumps(_sanitize_params_for_history(params)),
|
|
_json.dumps(result.get('urls') or []),
|
|
long_image_url,
|
|
),
|
|
)
|
|
history_id = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
if history_id is not None:
|
|
result['history_id'] = history_id
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
return jsonify({'success': False, 'urls': [], 'error': str(e)})
|
|
|
|
|
|
@image_bp.route('/api/version')
|
|
def api_version():
|
|
"""检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较"""
|
|
current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip()
|
|
update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip()
|
|
result = {
|
|
'version': current_version,
|
|
'desc': '',
|
|
'url': '',
|
|
'has_update': False,
|
|
'latest_version': current_version,
|
|
'file_url': '',
|
|
}
|
|
if not update_url:
|
|
return jsonify(result)
|
|
try:
|
|
resp = requests.get(update_url, timeout=10)
|
|
resp.raise_for_status()
|
|
data = resp.json() or {}
|
|
latest_version = (data.get('version') or '').strip()
|
|
file_url = (data.get('file_url') or '').strip()
|
|
result['latest_version'] = latest_version
|
|
result['file_url'] = file_url
|
|
result['url'] = file_url
|
|
# 版本不一致则视为有更新
|
|
if latest_version and latest_version != current_version:
|
|
result['has_update'] = True
|
|
except Exception:
|
|
pass
|
|
return jsonify(result)
|
|
|
|
|
|
def _run_update_and_exit(zip_path, target_dir):
|
|
"""在后台延迟后启动 update.exe(脱离当前进程),然后退出当前程序"""
|
|
def _do():
|
|
import time
|
|
time.sleep(1.5) # 确保 HTTP 响应已发送
|
|
# exe_dir = target_dir
|
|
# exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist"
|
|
exe_dir = os.path.join(BASE_DIR,"update")
|
|
update_exe = os.path.join(exe_dir, 'update.exe')
|
|
if not os.path.isfile(update_exe):
|
|
return
|
|
try:
|
|
creationflags = 0
|
|
if sys.platform == 'win32':
|
|
creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
subprocess.Popen(
|
|
[update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name],
|
|
cwd=exe_dir,
|
|
creationflags=creationflags,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
close_fds=True,
|
|
)
|
|
except Exception:
|
|
pass
|
|
os._exit(0)
|
|
t = threading.Thread(target=_do, daemon=False)
|
|
t.start()
|
|
|
|
|
|
@image_bp.route('/api/update/do', methods=['POST'])
|
|
def api_update_do():
|
|
"""执行更新:下载 zip 到 tmp,启动 update.exe 后退出程序"""
|
|
data = request.get_json() or {}
|
|
file_url = (data.get('file_url') or '').strip()
|
|
if not file_url:
|
|
return jsonify({'success': False, 'error': '缺少 file_url'}), 400
|
|
parsed = urlparse(file_url)
|
|
if parsed.scheme not in ('http', 'https'):
|
|
return jsonify({'success': False, 'error': '无效的下载地址'}), 400
|
|
tmp_dir = os.path.join(BASE_DIR, 'tmp')
|
|
try:
|
|
os.makedirs(tmp_dir, exist_ok=True)
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500
|
|
# 使用 URL 中的文件名或默认版本名
|
|
filename = os.path.basename(parsed.path) or 'update.zip'
|
|
zip_path = os.path.join(tmp_dir, filename)
|
|
try:
|
|
resp = requests.get(file_url, timeout=300, stream=True)
|
|
resp.raise_for_status()
|
|
with open(zip_path, 'wb') as f:
|
|
for chunk in resp.iter_content(chunk_size=65536):
|
|
if chunk:
|
|
f.write(chunk)
|
|
except requests.RequestException as e:
|
|
return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502
|
|
_run_update_and_exit(zip_path, BASE_DIR)
|
|
return jsonify({'success': True, 'message': '更新已启动,程序即将退出'})
|
|
|
|
|
|
@image_bp.route('/api/download')
|
|
@login_required
|
|
def api_download():
|
|
"""代理下载图片,解决跨域 fetch 无法下载的问题"""
|
|
url = request.args.get('url', '').strip()
|
|
filename = request.args.get('filename', 'image.png')
|
|
if not url:
|
|
return jsonify({'success': False, 'error': '缺少 url 参数'}), 400
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ('http', 'https'):
|
|
return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400
|
|
try:
|
|
resp = requests.get(url, timeout=30, stream=True)
|
|
resp.raise_for_status()
|
|
content_type = resp.headers.get('Content-Type', 'image/png')
|
|
encoded = quote(filename, safe='')
|
|
disposition = f"attachment; filename*=UTF-8''{encoded}"
|
|
return Response(
|
|
resp.iter_content(chunk_size=8192),
|
|
mimetype=content_type,
|
|
headers={'Content-Disposition': disposition}
|
|
)
|
|
except requests.RequestException as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 502
|
|
|
|
|
|
@image_bp.route('/api/stitch/save', methods=['POST'])
|
|
@login_required
|
|
def api_stitch_save():
|
|
"""手动拼接:接收图片 URL 列表(支持 http 或 data URL),拼接并上传,返回长图 URL"""
|
|
try:
|
|
data = request.get_json() or {}
|
|
urls = data.get('urls')
|
|
if not urls or not isinstance(urls, list):
|
|
return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400
|
|
urls = [u for u in urls if u and isinstance(u, str)]
|
|
if not urls:
|
|
return jsonify({'success': False, 'error': '没有有效的图片'}), 400
|
|
long_image_url = _stitch_and_upload_long_image(urls)
|
|
if not long_image_url:
|
|
return jsonify({'success': False, 'error': '拼接或上传失败'}), 500
|
|
return jsonify({'success': True, 'long_image_url': long_image_url})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@image_bp.route('/api/history')
|
|
@login_required
|
|
def api_history():
|
|
"""分页获取当前用户的历史图库,支持按 panel_type 栏目筛选"""
|
|
page = max(1, int(request.args.get('page', 1)))
|
|
page_size = min(50, max(10, int(request.args.get('page_size', 20))))
|
|
panel_type = (request.args.get('panel_type') or '').strip()
|
|
offset = (page - 1) * page_size
|
|
try:
|
|
conn = get_db()
|
|
with conn.cursor() as cur:
|
|
where_user = "user_id = %s"
|
|
params_where = [session['user_id']]
|
|
if panel_type:
|
|
where_user += " AND panel_type = %s"
|
|
params_where.append(panel_type)
|
|
cur.execute(
|
|
"""SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url
|
|
FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""",
|
|
params_where + [page_size, offset],
|
|
)
|
|
rows = cur.fetchall()
|
|
cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where)
|
|
total = cur.fetchone()['total']
|
|
conn.close()
|
|
|
|
def _parse_json(val, default=None):
|
|
if val is None:
|
|
return default if default is not None else []
|
|
if isinstance(val, (list, dict)):
|
|
return val
|
|
try:
|
|
return json.loads(val)
|
|
except Exception:
|
|
return default if default is not None else []
|
|
|
|
items = []
|
|
for r in rows:
|
|
items.append({
|
|
'id': r['id'],
|
|
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
|
'panel_type': r['panel_type'] or '',
|
|
'original_urls': _parse_json(r['original_urls'], []),
|
|
'params': _parse_json(r['params'], {}),
|
|
'result_urls': _parse_json(r['result_urls'], []),
|
|
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
|
})
|
|
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)})
|
|
|
|
|
|
|
|
|
|
|