diff --git a/.gitignore b/.gitignore index e9ab71b..f5bb90a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ -.vscode -venv +.vscode/ +venv/ +saved_poses/ \ No newline at end of file diff --git a/app.py b/app.py index 669787f..77ee30d 100644 --- a/app.py +++ b/app.py @@ -2,12 +2,130 @@ if __name__ == '__main__': import mimetypes mimetypes.add_type('application/javascript', '.js') - from flask import Flask + import os, sys, importlib, glob, json, base64, traceback, re + from functools import wraps + from io import BytesIO + from flask import Flask, jsonify, request + + if importlib.util.find_spec('PIL') is not None: + from PIL import Image, PngImagePlugin + else: + import subprocess + try: + print('-' * 80, file=sys.stderr) + print('| installing PIL (pillow) ...') + print('-' * 80, file=sys.stderr) + subprocess.check_call( + [sys.executable, "-m", "pip", "install", 'pillow'], + stdout=sys.stdout, + stderr=sys.stderr + ) + except Exception as e: + msg = ''.join(traceback.TracebackException.from_exception(e).format()) + print(msg, file=sys.stderr) + print('-' * 80, file=sys.stderr) + print('| failed to install PIL (pillow). exit...', file=sys.stderr) + print('-' * 80, file=sys.stderr) + sys.exit(1) + from PIL import Image, PngImagePlugin app = Flask(__name__, static_folder='.', static_url_path='') + + def atoi(text): + return int(text) if text.isdigit() else text + def natural_keys(text): + return [ atoi(c) for c in re.split(r'(\d+)', text) ] + def sorted_glob(path): + return sorted(glob.glob(path), key=natural_keys) + + def get_saved_poses(): + dirpath = os.path.join(app.static_folder, 'saved_poses') + for path in sorted_glob(f'{dirpath}/*.png'): + yield Image.open(path) @app.route('/') def index(): return app.send_static_file('index.html') + + def api_try(fn): + @wraps(fn) + def f(*args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception as e: + msg = ''.join(traceback.TracebackException.from_exception(e).format()) + print(msg, file=sys.stderr) + return jsonify(result=str(e), ok=False) + return f + + @app.route('/pose/all') + @api_try + def saved_poses(): + result = [] + for img in get_saved_poses(): + buffer = BytesIO() + img.save(buffer, format='png') + + if not hasattr(img, 'text'): + continue + + result.append({ + 'name': img.text['name'], + 'image': base64.b64encode(buffer.getvalue()).decode('ascii'), + 'screen': json.loads(img.text['screen']), + 'camera': json.loads(img.text['camera']), + 'joints': json.loads(img.text['joints']), + }) + return jsonify(result) + + def name2path(name: str): + if not isinstance(name, str): + raise ValueError(f'str object expected, but {type(name)}') + if len(name) == 0: + raise ValueError(f'empty name') + if '.' in name or '/' in name or '\\' in name: + raise ValueError(f'invalid name: {name}') + path = os.path.realpath(os.path.join(app.static_folder, 'saved_poses', f'{name}.png')) + prefix = os.path.realpath(os.path.join(app.static_folder, 'saved_poses')) + if not path.startswith(prefix): + raise ValueError(f'invalid name: {name}') + return path + + @app.route('/pose/save', methods=['POST']) + @api_try + def save_pose(): + data = request.json + + name = data['name'] + screen = data['screen'] + camera = data['camera'] + joints = data['joints'] + + info = PngImagePlugin.PngInfo() + info.add_text('name', name) + info.add_text('screen', json.dumps(screen)) + info.add_text('camera', json.dumps(camera)) + info.add_text('joints', json.dumps(joints)) + + filepath = name2path(name) + + image = Image.open(BytesIO(base64.b64decode(data['image'][len('data:image/png;base64,'):]))) + unit = max(image.width, image.height) + mx, my = (unit - image.width) // 2, (unit - image.height) // 2 + canvas = Image.new('RGB', (unit, unit), color=(68, 68, 68)) + canvas.paste(image, (mx, my)) + image = canvas.resize((canvas.width//4, canvas.height//4)) + + image.save(filepath, pnginfo=info) + + return jsonify(result='pose saved', ok=True) + + @app.route('/pose/delete', methods=['POST']) + @api_try + def delete_pose(): + data = request.json + filepath = name2path(data['name']) + os.remove(filepath) + return jsonify(result='pose deleted', ok=True) app.run(port=55502, debug=True) diff --git a/css/main.css b/css/main.css index 40a1414..2fa2b2f 100644 --- a/css/main.css +++ b/css/main.css @@ -68,4 +68,58 @@ position: absolute; outline: 1px solid gray; pointer-events: none; +} + +#saved_poses { + display: flex; + flex-direction: row; + gap: 0.25em; + font-size: small; +} + +#saved_poses > * { + margin: 0; + outline: 1px solid gray; + max-width: 128px; +} + +#saved_poses img { + max-width: 128px; + max-height: 128px; +} + +#saved_poses figcaption { + padding: 0 0.25em; + overflow-wrap: anywhere; /* Opera Android may not be able to interpret `anywhere` keyword. */ +} + +#saved_poses .close { + position: absolute; + cursor: pointer; + border: 1px solid gray; + background-color: white; + opacity: 0.5; + width: 1.25em; + height: 1.25em; + text-align: center; + vertical-align: middle; + margin: 0; + font-family: monospace; +} + +#saved_poses .close:hover { + opacity: 1.0; +} + +#saved_poses .close2 { + display: none; + position: absolute; + left: 1.5em; + top: 0; +} + +#saved_poses .close:hover .close2 { + display: block; + color: white; + pointer-events: none; } \ No newline at end of file diff --git a/index.html b/index.html index 34fbd49..15d99f1 100644 --- a/index.html +++ b/index.html @@ -52,8 +52,12 @@
- 💾 Save - 📋 Copy to clipboard +
+ 💾 Save Image + 📋 Copy to clipboard +
+ +

diff --git a/js/app.js b/js/app.js index 29d0179..6601a1f 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,80 @@ +const POSES = new Map(); + +function notify(str, type) { + if (type === undefined) type = 'success'; + + switch (type) { + case 'success': console.log(str); break; + case 'info': console.log(str); break; + case 'warn': console.warn(str); break; + case 'error': console.error(str); break; + } + + const p = document.createElement('p'); + p.textContent = str; + p.classList.add('item', type); + const cont = document.querySelector('#notifications'); + cont.appendChild(p); + setTimeout(() => cont.removeChild(p), 3000); +} + +async function save_pose(obj) { + const res = await fetch('/pose/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(obj), + }); + const result = await res.json(); + if (result.ok) reload_poses(); + return result; +} + +async function delete_pose(name) { + const res = await fetch('/pose/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const result = await res.json(); + notify(result.result, result.ok ? 'success' : 'error'); + if (result.ok) reload_poses(); + return result; +} + +async function reload_poses() { + POSES.clear(); + + const res = await fetch('/pose/all'); + const cont = document.querySelector('#saved_poses'); + cont.innerHTML = ''; + const df = document.createDocumentFragment(); + for (let data of await res.json()) { + POSES.set(data.name, data); + + const fig = document.createElement('figure') + const img = document.createElement('img'); + const cap = document.createElement('figcaption'); + const clo = document.createElement('div'); + const clo2 = document.createElement('span'); + fig.dataset.poseName = data.name; + cap.textContent = data.name; + clo.textContent = 'x'; + clo.classList.add('close'); + clo2.classList.add('close2'); + clo2.textContent = 'delete'; + clo.appendChild(clo2); + + img.src = 'data:image/png;base64,' + data.image; + img.title = data.name; + fig.append(clo, img, cap); + + df.appendChild(fig); + } + cont.appendChild(df); +} + document.addEventListener('DOMContentLoaded', async () => { - const { init, init_3d } = await import('posex'); + const ui = { container: document.querySelector('#cont'), canvas: document.querySelector('#main_canvas'), @@ -17,21 +92,49 @@ document.addEventListener('DOMContentLoaded', async () => { reset_bg: document.querySelector('#reset_bg'), save: document.querySelector('#save_button'), copy: document.querySelector('#copy_button'), - notify: function () { - function notify(str, type) { - if (type === undefined) type = 'success'; - - const p = document.createElement('p'); - p.textContent = str; - p.classList.add('item', type); - const cont = document.querySelector('#notifications'); - cont.appendChild(p); - setTimeout(() => cont.removeChild(p), 3000); - } - }, + save_pose: document.querySelector('#save_pose'), + save_pose_callback: save_pose, + notify: notify, }; + document.addEventListener('poseload', e => { + const obj = POSES.get(e.detail.name); + if (obj) ui.loadPose(obj); + }, false); + + const { init, init_3d } = await import('posex'); + init(ui); const animate = init_3d(ui); animate(); + + await reload_poses(); + +}, false); + +document.addEventListener('DOMContentLoaded', () => { + const get_name = ele => { + while (ele && ele !== document) { + if (ele.dataset && ele.dataset.poseName !== undefined) + return ele.dataset.poseName; + ele = ele.parentNode; + } + return ''; + }; + + document.querySelector('#saved_poses').addEventListener('click', e => { + let target = e.target; + if (target.tagName === 'IMG') target = target.parentNode; + if (target.classList.contains('close2')) target = target.parentNode; + if (target.tagName === 'FIGURE') { + const name = get_name(target); + const ev = new CustomEvent('poseload', { bubbles: true, detail: { name } }); + target.dispatchEvent(ev); + } else if (target.classList.contains('close')) { + const name = get_name(target); + if (name.length != 0) { + delete_pose(name); + } + } + }, false); }, false); diff --git a/js/posex.js b/js/posex.js index f8a2508..8f974b6 100644 --- a/js/posex.js +++ b/js/posex.js @@ -233,6 +233,8 @@ function init_3d(ui) { bodies.set(name, body); scene.add(group); touchable_bodies.push(group); + + return body; }; const remove_body = name => { @@ -445,6 +447,53 @@ function init_3d(ui) { if (ui.reset_bg) ui.reset_bg.addEventListener('click', () => set_bg(null), false); + function get_pose_dict(obj3d) { + return { + position: obj3d.position.toArray(), + rotation: obj3d.rotation.toArray(), + scale: obj3d.scale.toArray(), + up: obj3d.up.toArray(), + }; + } + + function set_pose_dict(obj3d, dict) { + obj3d.position.set(...dict.position); + obj3d.rotation.set(...dict.rotation); + obj3d.scale.set(...dict.scale); + obj3d.up.set(...dict.up); + } + + if (ui.save_pose && ui.save_pose_callback) + ui.save_pose.addEventListener('click', async () => { + const name = prompt('Input pose name.'); + if (name === undefined || name === null || name === '') return; + + const screen = { + width: width(), + height: height(), + } + + const camera_ = get_pose_dict(camera); + + const joints = []; + for (let [name, body] of bodies) { + joints.push({ + name, + joints: body.joints.map(j => get_pose_dict(j)), + group: get_pose_dict(body.group), + x0: body.x0, + y0: body.y0, + z0: body.z0, + }); + } + + const image = await ui.getDataURL(); + + const data = { name, image, screen, camera: camera_, joints }; + const result = await ui.save_pose_callback(data); + ui.notify(result.result, result.ok ? 'success' : 'error'); + }, false); + const onAnimateEndOneshot = []; const animate = () => { @@ -499,6 +548,34 @@ function init_3d(ui) { onAnimateEndOneshot.length = 0; }; + ui.loadPose = function(data) { + selected_body = null; + touched_body = null; + touchable_objects.length = 0; + touchable_bodies.length = 0; + object_to_body.clear(); + for (let name of bodies.keys()) { + remove_body(name); + } + + // screen + size_change(data.screen.width, data.screen.height); + if (width_input) width_input.value = data.screen.width; + if (height_input) height_input.value = data.screen.height; + + // camera + set_pose_dict(camera, data.camera); + + // bodies + for (let dict of data.joints) { + const body = add_body(dict.name, dict.x0, dict.y0, dict.z0); + for (let i = 0, e = Math.min(body.joints.length, dict.joints.length); i < e; ++i) { + set_pose_dict(body.joints[i], dict.joints[i]); + } + set_pose_dict(body.group, dict.group); + } + }; + ui.getDataURL = async function() { const pr = new Promise(resolve => { const current_bg = scene.background; diff --git a/requirements.txt b/requirements.txt index 7e10602..4111845 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask +pillow