""" 图片生成蓝图:生成、历史、下载、拼接、版本 """ 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)})