diff --git a/.github/workflows/build_docker_image.yml b/.github/workflows/build_docker_image.yml index 12a3acb..290330f 100644 --- a/.github/workflows/build_docker_image.yml +++ b/.github/workflows/build_docker_image.yml @@ -77,5 +77,5 @@ jobs: build-args: | GIT_COMMIT=${{ env.BUILD_HASH }} DEBUG=False - tags: ${{ env.CUSTOM_TAG }} + tags: ${{ env.CUSTOM_TAG }},ghcr.io/snowsune/size-diff:latest labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 0de5fc4..269d87a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ cache *.*~ .env *.sqlite3 +art/dist/ diff --git a/Dockerfile b/Dockerfile index 73cb0e5..523e732 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,9 @@ RUN pip install --upgrade pip && pip install -r requirements.txt # Copy the app code to the container COPY . . +# Run the art trimming script +RUN python3 scripts/trim_art.py + # Expose port 5000 for the Flask app EXPOSE 5000 diff --git a/app/__init__.py b/app/__init__.py index 26089b6..37d464f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -32,7 +32,7 @@ get_default_characters, ) from app.utils.stats import StatsManager -from app.utils.generate_image import render_image +from app.utils.generate_image_legacy import render_image from app.utils.character import Character app = Flask(__name__) @@ -83,7 +83,7 @@ def wrapped(*args, **kwargs): @app.route("/generate-image") @cache_with_stats(timeout=31536000, query_string=True) -def generate_image(): +def generate_image_legacy(): # Get characters characters = request.args.get("characters", "") characters_list = extract_characters(characters) @@ -152,6 +152,13 @@ def index(): characters = request.args.get("characters", "") characters_list = extract_characters(characters) + # Process characters to populate image and ears_offset from species data + processed_characters = [] + for char in characters_list: + processed_char = calculate_height_offset(char, use_species_scaling=False) + processed_characters.append(processed_char) + characters_list = processed_characters + # Extract settings from query string measure_ears = request.args.get("measure_ears", "true") == "true" scale_height = request.args.get("scale_height", "false") == "true" @@ -165,12 +172,17 @@ def index(): # Insert default character values if none exist if len(characters_list) == 0: - characters_list = get_default_characters() + default_chars = get_default_characters() + # Process default characters to populate image and ears_offset + characters_list = [] + for char in default_chars: + processed_char = calculate_height_offset(char, use_species_scaling=False) + characters_list.append(processed_char) # Load presets for the dropdown presets = load_preset_characters() preset_map = { - f"{p['name']} ({p['species'].replace('_', ' ').title()}, {p['gender']}, {p['height']}in)": f"{p['species']},{p['gender']},{p['height']},{p['name']}" + f"{p['name'].replace('_', ' ').title()} --- {p['species'].replace('_', ' ').title()}, {p['gender']}, {p.get('description', '')}": f"{p['species']},{p['gender']},{p['height']},{p['name']}" for p in presets } @@ -218,12 +230,32 @@ def index(): settings_query = f"&measure_ears=false" if not measure_ears else "" settings_query += f"&scale_height=true" if scale_height else "" + # Convert Character objects to JSON-serializable dictionaries for JavaScript + characters_json_data = [] + for char in characters_list: + char_dict = { + "name": char.name, + "species": char.species, + "gender": char.gender, + "height": char.height, + "feral_height": char.feral_height, + "image": char.image, + "ears_offset": char.ears_offset, + "color": getattr(char, "color", None), + } + characters_json_data.append(char_dict) + + import json + + characters_json = json.dumps(characters_json_data) + return render_template( "index.html", stats=stats, cache_performance=f"{cache_stats['hits']}/{cache_stats['misses']}", species=species_list, characters_list=characters_list, + characters_json=characters_json, characters_query=generate_characters_query_string(characters_list), settings_query=settings_query, measure_ears=measure_ears, @@ -300,6 +332,115 @@ def add_preset(): return redirect(f"/?characters={characters_query}{settings_query}") +@app.route("/taur") +def taur(): + """ + Base route for volnar's sub-page! + """ + + return render_template("taur.html") + + +# Interactive demo route +@app.route("/interactive-demo") +def interactive_demo(): + return render_template("interactive_demo.html") + + +# Serve species data images for the client-side renderer +@app.route("/species_data/") +def serve_species_image(filename): + from flask import send_from_directory + import os + + # Get the absolute path to the species_data directory + species_data_path = os.path.join(os.path.dirname(__file__), "species_data") + return send_from_directory(species_data_path, filename) + + +# New universal renderer endpoint (replaces the old Python renderer) +@app.route("/generate-image-new") +@cache_with_stats(timeout=31536000, query_string=True) +def generate_image(): + # Get characters + characters = request.args.get("characters", "") + characters_list = extract_characters(characters) + + # Get settings + measure_ears = request.args.get("measure_ears", True) == "True" + scale_height = request.args.get("scale_height", True) == "True" + + # Get height + size = int(request.args.get("size", "400")) + + # Record we've generated a new image! + stats_manager.increment_images_generated() + + def generate_and_save(): + if len(characters_list) == 0: + logging.warn("Asked to generate an empty image!") + # Generate an empty image + image = Image.new("RGB", (int(size * 1.4), size)) + pixels = image.load() + for i in range(image.size[0]): + for j in range(image.size[1]): + pixels[i, j] = ( + random.randint(0, 255), + random.randint(0, 255), + random.randint(0, 255), + ) + else: + # Use the JavaScript renderer with fallback to Python PIL + from app.utils.js_renderer import render_with_js_fallback + + # Convert characters to dictionaries for JS renderer + char_dicts = [] + for char in characters_list: + char_dict = { + "name": char.name, + "species": char.species, + "height": char.height, + "gender": char.gender, + "feral_height": char.feral_height, + "image": char.image, + "ears_offset": char.ears_offset, + } + if hasattr(char, "color") and char.color: + char_dict["color"] = char.color + char_dicts.append(char_dict) + + options = { + "size": size, + "measureToEars": measure_ears, + "useSpeciesScaling": scale_height, + } + + # This will try JS renderer first, fall back to Python PIL automatically + image = render_with_js_fallback(char_dicts, options) + + # Save image to a BytesIO object + img_io = io.BytesIO() + image.save(img_io, "PNG") + img_io.seek(0) + return img_io + + # Submit the task to the executor + future = executor.submit(generate_and_save) + + try: + img_io = future.result(timeout=30) # Wait for up to 30 seconds + except TimeoutError: + return "Image generation timed out", 504 + + # Create a response with the image and set Content-Type to image/png + response = make_response(img_io.read()) + response.headers.set("Content-Type", "image/png") + response.headers.set("Content-Disposition", "inline", filename="preview.png") + response.headers.set("Cache-Control", "public, max-age=31536000") + + return response + + # For WSGI def create_app(): return app diff --git a/app/species_data/Hieght_Ref_Ky-Li_for_Vixi.png b/app/species_data/Hieght_Ref_Ky-Li_for_Vixi.png deleted file mode 100644 index 6d9a511..0000000 Binary files a/app/species_data/Hieght_Ref_Ky-Li_for_Vixi.png and /dev/null differ diff --git a/app/species_data/Hieght_Ref_Maxene_for_Vixi.png b/app/species_data/Hieght_Ref_Maxene_for_Vixi.png deleted file mode 100644 index 415d50a..0000000 Binary files a/app/species_data/Hieght_Ref_Maxene_for_Vixi.png and /dev/null differ diff --git a/app/species_data/african_buffalo.yaml b/app/species_data/african_buffalo.yaml index 47f802c..e9dccc2 100644 --- a/app/species_data/african_buffalo.yaml +++ b/app/species_data/african_buffalo.yaml @@ -1,5 +1,5 @@ male: - image: "missing.png" + image: "Chrissy/missing.png" ears_offset: 0 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 50 female: - image: "missing.png" + image: "Chrissy/missing.png" ears_offset: 0 data: - anthro_size: 74 # 6'2" diff --git a/app/species_data/arctic_fox.yaml b/app/species_data/arctic_fox.yaml index b6e7ab1..b2cdbc7 100644 --- a/app/species_data/arctic_fox.yaml +++ b/app/species_data/arctic_fox.yaml @@ -1,5 +1,5 @@ male: - image: "randal.png" + image: "Chrissy/randal.png" ears_offset: 4 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 15 # 15" female: - image: "vixi.png" + image: "Chrissy/vixi.png" ears_offset: 4 data: - anthro_size: 74 # 6'2" diff --git a/app/species_data/canine.yaml b/app/species_data/canine.yaml index 293f6eb..0b8e618 100644 --- a/app/species_data/canine.yaml +++ b/app/species_data/canine.yaml @@ -1,5 +1,5 @@ male: - image: "Hieght_Ref_Maxene_for_Vixi.png" + image: "Hunner/Hieght_Ref_Maxene_for_Vixi.png" ears_offset: 3 color: "870716" data: @@ -9,7 +9,7 @@ male: height: 24 female: - image: "Hieght_Ref_Ky-Li_for_Vixi.png" + image: "Hunner/Hieght_Ref_Ky-Li_for_Vixi.png" ears_offset: 9 color: "add6ed" data: diff --git a/app/species_data/equine.yaml b/app/species_data/equine.yaml index 9818375..6883e3a 100644 --- a/app/species_data/equine.yaml +++ b/app/species_data/equine.yaml @@ -1,5 +1,5 @@ male: - image: "f_equine.png" + image: "Placeholder/f_equine.png" ears_offset: 0 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 56 female: - image: "f_equine.png" + image: "Placeholder/f_equine.png" ears_offset: 0 data: - anthro_size: 76 # 6'4" diff --git a/app/species_data/f_mouse_hunner.png b/app/species_data/f_mouse_hunner.png deleted file mode 100644 index abc7032..0000000 Binary files a/app/species_data/f_mouse_hunner.png and /dev/null differ diff --git a/app/species_data/feline.yaml b/app/species_data/feline.yaml index d21536b..238de43 100644 --- a/app/species_data/feline.yaml +++ b/app/species_data/feline.yaml @@ -1,5 +1,5 @@ male: - image: "Felid_Sketch_Male.png" + image: "Rhainbowmetall/Felid_Sketch_Male.png" ears_offset: 9 color: "282b1d" data: @@ -9,7 +9,7 @@ male: height: 10 female: - image: "Felid_Sketch_Female.png" + image: "Rhainbowmetall/Felid_Sketch_Female.png" ears_offset: 9 color: "1d751d" data: diff --git a/app/species_data/fennec_fox.yaml b/app/species_data/fennec_fox.yaml index af8b0e7..0acb5e0 100644 --- a/app/species_data/fennec_fox.yaml +++ b/app/species_data/fennec_fox.yaml @@ -1,5 +1,5 @@ male: - image: "m_fennec.png" + image: "Chrissy/m_fennec.png" ears_offset: 10 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 7 female: - image: "missing.png" + image: "Chrissy/missing.png" ears_offset: 0 data: - anthro_size: 74 # 6'2" diff --git a/app/species_data/giraffe.yaml b/app/species_data/giraffe.yaml index 508cb7d..ff95520 100644 --- a/app/species_data/giraffe.yaml +++ b/app/species_data/giraffe.yaml @@ -1,5 +1,5 @@ male: - image: "m_giraffe.png" + image: "Placeholder/m_giraffe.png" ears_offset: 0 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 190 female: - image: "m_giraffe.png" + image: "Placeholder/m_giraffe.png" ears_offset: 0 data: - anthro_size: 76 # 6'4" diff --git a/app/species_data/m_mouse_hunner.png b/app/species_data/m_mouse_hunner.png deleted file mode 100644 index c8f115b..0000000 Binary files a/app/species_data/m_mouse_hunner.png and /dev/null differ diff --git a/app/species_data/mouse.yaml b/app/species_data/mouse.yaml index b5dde08..bc8f116 100644 --- a/app/species_data/mouse.yaml +++ b/app/species_data/mouse.yaml @@ -1,7 +1,7 @@ male: - image: "m_mouse_hunner.png" + image: "Hunner/Size_Refs_Mouse_for_Vixi (male).png" ears_offset: 6 - color: "8B8000" + color: "007516" data: - anthro_size: 76 # 6'4" height: 3 @@ -9,9 +9,9 @@ male: height: 4.5 female: - image: "f_mouse_hunner.png" + image: "Hunner/Size_Refs_Mouse_for_Vixi (female).png" ears_offset: 7 - color: "049904" + color: "7900ad" data: - anthro_size: 74 # 6'2" height: 2.5 diff --git a/app/species_data/preset_species.yaml b/app/species_data/preset_species.yaml index 2ac0869..2c9baca 100644 --- a/app/species_data/preset_species.yaml +++ b/app/species_data/preset_species.yaml @@ -37,7 +37,17 @@ presets: height: 66 description: All things foxy vixen! With 100% bonus wings! - name: Tirga - species: feline + species: saber-toothed_tiger gender: androgynous - height: 72 + height: 84 description: She's got more limbs than she knows what to do with. + - name: Alice + species: canine + gender: female + height: 141 + description: Big ol' dog~ + - name: Maxene + species: canine + gender: male + height: 120 + description: Cute demon doggo! diff --git a/app/species_data/red_fox.yaml b/app/species_data/red_fox.yaml index a114d72..afe5cec 100644 --- a/app/species_data/red_fox.yaml +++ b/app/species_data/red_fox.yaml @@ -1,5 +1,5 @@ male: - image: "randal.png" + image: "Chrissy/randal.png" ears_offset: 4 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 15 female: - image: "sinopa.png" + image: "Chrissy/sinopa.png" ears_offset: 1.5 data: - anthro_size: 76 # 6'4" diff --git a/app/species_data/rexouium.yaml b/app/species_data/rexouium.yaml index c8f499c..36cfbda 100644 --- a/app/species_data/rexouium.yaml +++ b/app/species_data/rexouium.yaml @@ -1,5 +1,5 @@ male: - image: "m_rexo.png" + image: "Chrissy/m_rexo.png" ears_offset: 7.5 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 60 # From chrissy female: - image: "chrissy.png" + image: "Chrissy/chrissy.png" ears_offset: 6 data: - anthro_size: 76 # 6'4" diff --git a/app/species_data/saber-toothed_tiger.yaml b/app/species_data/saber-toothed_tiger.yaml new file mode 100644 index 0000000..62dc89c --- /dev/null +++ b/app/species_data/saber-toothed_tiger.yaml @@ -0,0 +1,19 @@ +male: + image: "Hunner/Hieght_Ref_NO_BAK_Tirga_for_Tirga.png" + ears_offset: 3 + color: "00730b" + data: + - anthro_size: 76 # 6'4" + height: 44 + - anthro_size: 60 # 5' + height: 38 + +female: + image: "Hunner/Hieght_Ref_NO_BAK_Tirga_for_Tirga.png" + ears_offset: 3 + color: "b216f0" + data: + - anthro_size: 74 # 6'2" + height: 46 + - anthro_size: 60 # 5' + height: 34 diff --git a/app/species_data/shark.yaml b/app/species_data/shark.yaml index 583bf05..0d2f8b7 100644 --- a/app/species_data/shark.yaml +++ b/app/species_data/shark.yaml @@ -1,6 +1,6 @@ # Kamryn supplied an image and its, a little different but we can always change it later <3 male: - image: "m_shark.png" + image: "Mori/m_shark.png" ears_offset: 3 # color: "311b9e" data: @@ -10,7 +10,7 @@ male: height: 74 female: - image: "f_shark.png" + image: "Mori/f_shark.png" ears_offset: 3 # color: "664ce6" data: diff --git a/app/species_data/taur_(generic).yaml b/app/species_data/taur_(generic).yaml new file mode 100644 index 0000000..f9d73d8 --- /dev/null +++ b/app/species_data/taur_(generic).yaml @@ -0,0 +1,19 @@ +male: + image: "Volnar/TaurReference.png" + ears_offset: 1 + color: "590a1b" + data: + - anthro_size: 76 # 6'4" + height: 76 # + - anthro_size: 60 # 5' + height: 60 # + +female: + image: "Volnar/TaurReference.png" + ears_offset: 1 + color: "2c4b9e" + data: + - anthro_size: 76 # 6'4" + height: 76 # + - anthro_size: 60 # 5' + height: 60 # diff --git a/app/species_data/wolf.yaml b/app/species_data/wolf.yaml index fe84064..3dcb731 100644 --- a/app/species_data/wolf.yaml +++ b/app/species_data/wolf.yaml @@ -1,5 +1,5 @@ male: - image: "m_wolf.png" + image: "Placeholder/m_wolf.png" ears_offset: 0 data: - anthro_size: 76 # 6'4" @@ -8,7 +8,7 @@ male: height: 32 female: - image: "Hieght_Ref_Ky-Li_for_Vixi.png" + image: "Hunner/Hieght_Ref_Ky-Li_for_Vixi.png" ears_offset: 10 data: - anthro_size: 76 # 6'4" diff --git a/app/static/css/interactive.css b/app/static/css/interactive.css new file mode 100644 index 0000000..0c16719 --- /dev/null +++ b/app/static/css/interactive.css @@ -0,0 +1,180 @@ +/* Interactive Canvas Styles */ +.interactive-container { + /* Invisible container - just for layout */ + margin: 0; + background: transparent; + border: none; + padding: 0; +} + +.canvas-wrapper { + /* Wrapper sizes itself to the canvas */ + display: inline-block; + position: relative; + left: 50%; + transform: translateX(-50%); + + border: 2px solid #ddd; + border-radius: 8px; + margin-top: 15px; + margin-bottom: 15px; + background: white; + text-align: center; + overflow: visible; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 20px; + box-sizing: border-box; +} + +#main-canvas { + display: block; + /* Preserve aspect ratio and fit within viewport */ + max-width: calc(90vw - 60px); /* Account for wrapper padding and borders */ + max-height: 60vh; + width: auto; + height: auto; + /* Ensure aspect ratio is maintained */ + object-fit: contain; +} + +.character-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-top: 40px; + padding-top: 30px; + border-top: 1px solid #eee; +} + +.character-list-main, +.character-editor { + background: #2c3e50; + padding: 15px; + border-radius: 8px; + border: 1px solid #34495e; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.character-list-main h4, +.character-editor h4 { + margin-top: 0; + color: #ecf0f1; + font-size: 1.1rem; + border-bottom: 1px solid #34495e; + padding-bottom: 8px; +} + +#main-character-list { + max-height: 200px; + overflow-y: auto; +} + +.character-item-main { + background: #34495e; + margin: 5px 0; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid #4a6741; + cursor: pointer; + transition: all 0.2s; + color: #ecf0f1; +} + +.character-item-main:hover { + background: #4a6741; + border-color: #5dade2; + transform: translateY(-1px); +} + +.character-item-main.selected { + background: #3498db; + border-color: #2980b9; + font-weight: bold; + box-shadow: 0 2px 4px rgba(52, 152, 219, 0.3); + color: white; +} + +.character-properties-main { + display: none; +} + +.character-properties-main.active { + display: block; +} + +.property-row { + display: flex; + justify-content: space-between; + align-items: center; + margin: 10px 0; +} + +.property-row label { + font-weight: 500; + color: #bdc3c7; + min-width: 80px; +} + +.property-row input, +.property-row button { + padding: 5px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +.property-row input { + background: #34495e; + color: #ecf0f1; + min-width: 100px; + border: 1px solid #4a6741; +} + +.property-row input:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 3px rgba(52, 152, 219, 0.3); +} + +.property-row button { + background: #dc3545; + color: white; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} + +.property-row button:hover { + background: #c82333; +} + +#main-no-selection { + color: #95a5a6; + font-style: italic; + text-align: center; + padding: 20px; + background: #34495e; + border-radius: 4px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .character-controls { + grid-template-columns: 1fr; + gap: 15px; + } + + .canvas-wrapper { + margin: 10px 0; + } + + .property-row { + flex-direction: column; + align-items: stretch; + gap: 5px; + } + + .property-row label { + min-width: auto; + } +} \ No newline at end of file diff --git a/app/static/js/interactive-renderer.js b/app/static/js/interactive-renderer.js new file mode 100644 index 0000000..7676b3a --- /dev/null +++ b/app/static/js/interactive-renderer.js @@ -0,0 +1,324 @@ +/** + * Interactive Size Diff Renderer + * + * Extends the UniversalRenderer with client-side interactivity: + * - Drag and drop character reordering + * - Click to edit character properties (color, text position) + * - Real-time updates without server round trips + */ + +class InteractiveRenderer extends UniversalRenderer { + constructor(options = {}) { + super(options); + + this.isInteractive = true; + this.characters = []; + this.selectedCharacter = null; + this.isDragging = false; + this.dragStartPos = { x: 0, y: 0 }; + this.characterPositions = []; // Track character bounds for interaction + + // Event callbacks + this.onCharacterSelect = options.onCharacterSelect || (() => {}); + this.onCharactersReorder = options.onCharactersReorder || (() => {}); + this.onCharacterUpdate = options.onCharacterUpdate || (() => {}); + } + + async initialize(canvas = null) { + await super.initialize(canvas); + + if (this.isClientSide && this.canvas) { + this.setupEventListeners(); + } + } + + setupEventListeners() { + // Mouse events for interaction + this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this)); + this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); + this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this)); + this.canvas.addEventListener('click', this.handleClick.bind(this)); + + // Touch events for mobile + this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this)); + this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this)); + this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this)); + + // Prevent context menu on right click + this.canvas.addEventListener('contextmenu', e => e.preventDefault()); + } + + /** + * Update characters and re-render + */ + async updateCharacters(newCharacters) { + this.characters = [...newCharacters]; + await this.render(this.characters); + } + + /** + * Override render to track character positions for interaction + */ + async render(characters) { + this.characterPositions = []; + + const canvas = await super.render(characters); + + // Calculate character hit areas after rendering + this.calculateCharacterHitAreas(characters); + + return canvas; + } + + /** + * Calculate clickable areas for each character + */ + calculateCharacterHitAreas(characters) { + const layout = this.lastLayout; // Store layout in parent render method + let xOffset = 0; + + this.characterPositions = []; + + for (let i = 0; i < characters.length; i++) { + const dimensions = layout.characterDimensions[i]; + const yOffset = this.options.size - dimensions.height; + + this.characterPositions.push({ + index: i, + character: characters[i], + bounds: { + left: xOffset, + top: yOffset, + right: xOffset + dimensions.width, + bottom: yOffset + dimensions.height, + width: dimensions.width, + height: dimensions.height + } + }); + + xOffset += dimensions.width + layout.charPadding; + } + } + + /** + * Get character at mouse position + */ + getCharacterAtPosition(x, y) { + return this.characterPositions.find(pos => + x >= pos.bounds.left && + x <= pos.bounds.right && + y >= pos.bounds.top && + y <= pos.bounds.bottom + ); + } + + /** + * Get mouse position relative to canvas + */ + getMousePosition(event) { + const rect = this.canvas.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) * (this.canvas.width / rect.width), + y: (event.clientY - rect.top) * (this.canvas.height / rect.height) + }; + } + + handleMouseDown(event) { + const pos = this.getMousePosition(event); + const character = this.getCharacterAtPosition(pos.x, pos.y); + + if (character) { + this.selectedCharacter = character; + this.isDragging = true; + this.dragStartPos = pos; + this.canvas.style.cursor = 'grabbing'; + } + } + + handleMouseMove(event) { + const pos = this.getMousePosition(event); + + if (this.isDragging && this.selectedCharacter) { + // Visual feedback for dragging + this.canvas.style.cursor = 'grabbing'; + + // Could add visual drag preview here + + } else { + // Update cursor based on hover + const character = this.getCharacterAtPosition(pos.x, pos.y); + this.canvas.style.cursor = character ? 'grab' : 'default'; + } + } + + handleMouseUp(event) { + if (this.isDragging && this.selectedCharacter) { + const pos = this.getMousePosition(event); + this.handleDrop(pos); + } + + this.isDragging = false; + this.selectedCharacter = null; + this.canvas.style.cursor = 'default'; + } + + handleClick(event) { + const pos = this.getMousePosition(event); + const character = this.getCharacterAtPosition(pos.x, pos.y); + + if (character) { + this.onCharacterSelect(character.character, character.index); + } + } + + handleDrop(dropPos) { + if (!this.selectedCharacter) return; + + // Find which character position we're dropping onto + const targetCharacter = this.getCharacterAtPosition(dropPos.x, dropPos.y); + + if (targetCharacter && targetCharacter.index !== this.selectedCharacter.index) { + // Reorder characters + const newCharacters = [...this.characters]; + const [movedChar] = newCharacters.splice(this.selectedCharacter.index, 1); + newCharacters.splice(targetCharacter.index, 0, movedChar); + + this.characters = newCharacters; + this.onCharactersReorder(newCharacters); + + // Re-render with new order + this.render(this.characters); + } + } + + // Touch event handlers (delegate to mouse handlers) + handleTouchStart(event) { + event.preventDefault(); + const touch = event.touches[0]; + this.handleMouseDown({ clientX: touch.clientX, clientY: touch.clientY }); + } + + handleTouchMove(event) { + event.preventDefault(); + const touch = event.touches[0]; + this.handleMouseMove({ clientX: touch.clientX, clientY: touch.clientY }); + } + + handleTouchEnd(event) { + event.preventDefault(); + if (event.changedTouches.length > 0) { + const touch = event.changedTouches[0]; + this.handleMouseUp({ clientX: touch.clientX, clientY: touch.clientY }); + } + } + + /** + * Highlight a specific character + */ + highlightCharacter(index) { + if (!this.characterPositions[index]) return; + + const pos = this.characterPositions[index]; + + // Draw highlight overlay + this.ctx.save(); + this.ctx.strokeStyle = '#4CAF50'; + this.ctx.lineWidth = 3; + this.ctx.setLineDash([5, 5]); + this.ctx.strokeRect( + pos.bounds.left - 2, + pos.bounds.top - 2, + pos.bounds.width + 4, + pos.bounds.height + 4 + ); + this.ctx.restore(); + } + + /** + * Update a specific character property and re-render + */ + async updateCharacterProperty(index, property, value) { + if (index >= 0 && index < this.characters.length) { + this.characters[index] = { + ...this.characters[index], + [property]: value + }; + + await this.render(this.characters); + this.onCharacterUpdate(this.characters[index], index); + } + } + + /** + * Add a new character + */ + async addCharacter(character) { + this.characters.push(character); + await this.render(this.characters); + this.onCharacterUpdate(character, this.characters.length - 1); + } + + /** + * Remove a character + */ + async removeCharacter(index) { + if (index >= 0 && index < this.characters.length) { + const removed = this.characters.splice(index, 1)[0]; + await this.render(this.characters); + this.onCharactersReorder(this.characters); + return removed; + } + } + + /** + * Export current state as URL parameters (for sharing) + */ + exportAsURLParams() { + const charactersQuery = this.characters.map(char => + `${char.species},${char.gender},${char.height},${char.name}` + ).join('|'); + + const params = new URLSearchParams(); + params.set('characters', charactersQuery); + params.set('measure_ears', this.options.measureToEars); + params.set('scale_height', this.options.useSpeciesScaling); + params.set('size', this.options.size); + + return params.toString(); + } + + /** + * Load state from URL parameters + */ + loadFromURLParams(urlParams) { + const params = new URLSearchParams(urlParams); + + // Update options + this.options.measureToEars = params.get('measure_ears') !== 'false'; + this.options.useSpeciesScaling = params.get('scale_height') === 'true'; + this.options.size = parseInt(params.get('size')) || 400; + + // Parse characters + const charactersParam = params.get('characters'); + if (charactersParam) { + this.characters = charactersParam.split('|').map(charStr => { + const [species, gender, height, name] = charStr.split(','); + return { + species, + gender, + height: parseFloat(height), + name, + feral_height: parseFloat(height), // Default to same as height + image: `${species}_${gender}.png`, // Construct image filename + ears_offset: 0, // Default + color: null // Default + }; + }); + } + } +} + +// Export for browser use +if (typeof window !== 'undefined') { + window.InteractiveRenderer = InteractiveRenderer; +} \ No newline at end of file diff --git a/app/static/js/main-page-renderer.js b/app/static/js/main-page-renderer.js new file mode 100644 index 0000000..d345915 --- /dev/null +++ b/app/static/js/main-page-renderer.js @@ -0,0 +1,174 @@ +/** + * Main Page Interactive Renderer + * Handles the interactive canvas on the main index page + */ + +class MainPageRenderer { + constructor() { + this.renderer = null; + this.selectedCharacterIndex = -1; + this.characters = []; + this.options = {}; + } + + async init(charactersData, renderOptions) { + // Set up characters and options from data passed from server + this.characters = charactersData || []; + this.options = renderOptions || { + size: 1200, // Larger default size for better viewport usage + measureToEars: true, + useSpeciesScaling: false + }; + + // Initialize renderer + const canvas = document.getElementById('main-canvas'); + if (canvas && this.characters.length > 0) { + this.renderer = new InteractiveRenderer({ + size: this.options.size, + measureToEars: this.options.measureToEars, + useSpeciesScaling: this.options.useSpeciesScaling, + onCharacterSelect: this.onCharacterSelect.bind(this), + onCharactersReorder: this.onCharactersReorder.bind(this), + onCharacterUpdate: this.onCharacterUpdate.bind(this) + }); + + await this.renderer.initialize(canvas); + await this.renderer.updateCharacters(this.characters); + this.updateCharacterList(); + this.setupEventListeners(); + } + } + + setupEventListeners() { + // Character property changes + const nameInput = document.getElementById('main-char-name'); + const heightInput = document.getElementById('main-char-height'); + const colorInput = document.getElementById('main-char-color'); + const removeButton = document.getElementById('main-remove-character'); + + if (nameInput) { + nameInput.addEventListener('input', (e) => { + if (this.selectedCharacterIndex >= 0) { + this.renderer.updateCharacterProperty(this.selectedCharacterIndex, 'name', e.target.value); + this.updateCharacterList(); + } + }); + } + + if (heightInput) { + heightInput.addEventListener('input', (e) => { + if (this.selectedCharacterIndex >= 0) { + const height = parseFloat(e.target.value); + this.renderer.updateCharacterProperty(this.selectedCharacterIndex, 'height', height); + this.renderer.updateCharacterProperty(this.selectedCharacterIndex, 'feral_height', height); + this.updateCharacterList(); + } + }); + } + + if (colorInput) { + colorInput.addEventListener('input', (e) => { + if (this.selectedCharacterIndex >= 0) { + const color = e.target.value.substring(1); // Remove # from hex color + this.renderer.updateCharacterProperty(this.selectedCharacterIndex, 'color', color); + } + }); + } + + if (removeButton) { + removeButton.addEventListener('click', () => { + if (this.selectedCharacterIndex >= 0) { + this.renderer.removeCharacter(this.selectedCharacterIndex); + this.selectedCharacterIndex = -1; + this.updateCharacterList(); + this.updateCharacterProperties(); + } + }); + } + } + + onCharacterSelect(character, index) { + this.selectedCharacterIndex = index; + this.updateCharacterList(); + this.updateCharacterProperties(); + } + + onCharactersReorder(characters) { + this.characters = characters; + this.updateCharacterList(); + if (this.selectedCharacterIndex >= characters.length) { + this.selectedCharacterIndex = -1; + this.updateCharacterProperties(); + } + } + + onCharacterUpdate(character, index) { + this.updateCharacterList(); + } + + updateCharacterList() { + const listElement = document.getElementById('main-character-list'); + if (!listElement || !this.renderer) return; + + listElement.innerHTML = ''; + + this.renderer.characters.forEach((char, index) => { + const item = document.createElement('div'); + item.className = 'character-item-main'; + if (index === this.selectedCharacterIndex) { + item.classList.add('selected'); + } + + item.innerHTML = ` + ${char.name}
+ ${char.species.replace('_', ' ')} • ${char.height}" + `; + + item.addEventListener('click', () => { + this.onCharacterSelect(char, index); + }); + + listElement.appendChild(item); + }); + } + + updateCharacterProperties() { + const noSelection = document.getElementById('main-no-selection'); + const properties = document.getElementById('main-character-properties'); + + if (this.selectedCharacterIndex >= 0 && this.renderer) { + const char = this.renderer.characters[this.selectedCharacterIndex]; + + if (noSelection) noSelection.style.display = 'none'; + if (properties) properties.classList.add('active'); + + const nameInput = document.getElementById('main-char-name'); + const heightInput = document.getElementById('main-char-height'); + const colorInput = document.getElementById('main-char-color'); + + if (nameInput) nameInput.value = char.name; + if (heightInput) heightInput.value = char.height; + if (colorInput) colorInput.value = char.color ? `#${char.color}` : '#ffffff'; + } else { + if (noSelection) noSelection.style.display = 'block'; + if (properties) properties.classList.remove('active'); + } + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Get data from the page + const dataElement = document.getElementById('character-data'); + if (dataElement) { + try { + const charactersData = JSON.parse(dataElement.dataset.characters || '[]'); + const renderOptions = JSON.parse(dataElement.dataset.options || '{}'); + + const renderer = new MainPageRenderer(); + renderer.init(charactersData, renderOptions); + } catch (error) { + console.error('Failed to initialize main page renderer:', error); + } + } +}); \ No newline at end of file diff --git a/app/static/js/server-renderer.js b/app/static/js/server-renderer.js new file mode 100644 index 0000000..b26403a --- /dev/null +++ b/app/static/js/server-renderer.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +/** + * Server-side Universal Renderer for Node.js + * + * This script allows the Universal Renderer to work in a Node.js environment + * for server-side rendering. It uses node-canvas to provide a Canvas API. + */ + +const fs = require('fs'); +const path = require('path'); + +// Try to require node-canvas, exit gracefully if not available +let Canvas, Image; +try { + const canvas = require('canvas'); + Canvas = canvas.Canvas; + Image = canvas.Image; +} catch (error) { + console.error('node-canvas is required for server-side rendering'); + console.error('Install it with: npm install canvas'); + process.exit(1); +} + +// Load the Universal Renderer +// We need to simulate a browser-like environment +global.window = {}; +global.document = { + createElement: (tag) => { + if (tag === 'canvas') { + return new Canvas(); + } + return { + style: {}, + addEventListener: () => {}, + removeEventListener: () => {} + }; + } +}; + +// Load our renderer +const rendererPath = path.join(__dirname, 'universal-renderer.js'); +const rendererCode = fs.readFileSync(rendererPath, 'utf8'); + +// Remove browser-specific exports +const modifiedCode = rendererCode.replace(/if \(typeof window !== 'undefined'\) \{[\s\S]*?\}/, ''); +eval(modifiedCode); + +// Server-side Universal Renderer class +class ServerUniversalRenderer extends UniversalRenderer { + constructor(options = {}) { + super(options); + this.isClientSide = false; + } + + async initialize(canvas = null) { + if (canvas) { + this.canvas = canvas; + } else { + this.canvas = new Canvas(); + } + this.ctx = this.canvas.getContext('2d'); + } + + async loadCharacterImage(imagePath) { + if (this.characterImages.has(imagePath)) { + return this.characterImages.get(imagePath); + } + + // Convert web path to file system path + const filename = path.basename(imagePath); + const speciesDataPath = path.join(__dirname, '..', '..', 'species_data', filename); + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + this.characterImages.set(imagePath, img); + resolve(img); + }; + img.onerror = (err) => { + // Try fallback to missing.png + if (!imagePath.includes('missing.png')) { + const missingPath = path.join(path.dirname(speciesDataPath), 'missing.png'); + const fallbackImg = new Image(); + fallbackImg.onload = () => { + this.characterImages.set(imagePath, fallbackImg); + resolve(fallbackImg); + }; + fallbackImg.onerror = reject; + fallbackImg.src = missingPath; + } else { + reject(err); + } + }; + img.src = speciesDataPath; + }); + } + + exportAsBuffer(type = 'image/png') { + return this.canvas.toBuffer(type); + } + + exportAsBase64(type = 'image/png') { + return this.canvas.toDataURL(type).split(',')[1]; + } +} + +// Main function to run the renderer +async function main() { + try { + // Get input file path from command line + const inputPath = process.argv[2]; + if (!inputPath) { + console.error('Usage: node server-renderer.js '); + process.exit(1); + } + + // Read input data + const inputData = JSON.parse(fs.readFileSync(inputPath, 'utf8')); + const { characters, options = {} } = inputData; + + // Create renderer + const renderer = new ServerUniversalRenderer(options); + await renderer.initialize(); + + // Render characters + await renderer.render(characters); + + // Output base64 encoded image + const base64Data = renderer.exportAsBase64(); + console.log(base64Data); + + } catch (error) { + console.error('Rendering failed:', error.message); + process.exit(1); + } +} + +// Run if this script is executed directly +if (require.main === module) { + main(); +} + +module.exports = ServerUniversalRenderer; \ No newline at end of file diff --git a/app/static/js/universal-renderer.js b/app/static/js/universal-renderer.js new file mode 100644 index 0000000..ec64964 --- /dev/null +++ b/app/static/js/universal-renderer.js @@ -0,0 +1,396 @@ +/** + * Universal Size Diff Renderer + * + * This renderer can work both server-side (Node.js) and client-side (browser) + * to generate size comparison images. The core logic is shared between environments. + */ + +class UniversalRenderer { + constructor(options = {}) { + this.options = { + size: 400, + measureToEars: true, + useSpeciesScaling: false, + fontFamily: 'Arial, sans-serif', + fontSize: null, // Will be calculated from size + charPadding: null, // Will be calculated from fontSize + backgroundColor: 'white', + gridColor: 'grey', + gridLineWidth: 1, + ...options + }; + + this.canvas = null; + this.ctx = null; + this.isClientSide = typeof window !== 'undefined'; + this.characterImages = new Map(); // Cache for loaded images + } + + /** + * Initialize the renderer with a canvas or create one + */ + async initialize(canvas = null) { + if (this.isClientSide) { + if (canvas) { + this.canvas = canvas; + } else { + this.canvas = document.createElement('canvas'); + } + this.ctx = this.canvas.getContext('2d'); + } else { + // Server-side: would use node-canvas or similar + throw new Error('Server-side canvas not implemented yet'); + } + } + + /** + * Calculate dynamic sizes based on image size + */ + calculateDynamicSizes() { + const fontSize = Math.floor(this.options.size / 20); + const charPadding = fontSize * 6; + return { fontSize, charPadding }; + } + + /** + * Load character image (async for both client and server) + */ + async loadCharacterImage(imagePath) { + if (this.characterImages.has(imagePath)) { + return this.characterImages.get(imagePath); + } + + if (this.isClientSide) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + this.characterImages.set(imagePath, img); + resolve(img); + }; + img.onerror = (e) => { + console.warn(`Failed to load image: ${imagePath}`, e); + // Fallback to missing.png + if (!imagePath.includes('missing.png')) { + resolve(this.loadCharacterImage('/species_data/missing.png')); + } else { + reject(e); + } + }; + img.src = imagePath; + }); + } else { + // Server-side image loading would go here + throw new Error('Server-side image loading not implemented yet'); + } + } + + /** + * Apply color tint to character image using the alpha channel as a mask. + * This matches the Python PIL implementation for consistent color tinting. + */ + applyColorTint(imageElement, color) { + if (!color) return imageElement; // No change if no color is provided + + // Create a temporary canvas to apply the tint + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + + tempCanvas.width = imageElement.width; + tempCanvas.height = imageElement.height; + + // Draw the original image + tempCtx.drawImage(imageElement, 0, 0); + + // Apply color tint using composite operation + // This simulates PIL's composite with alpha mask + tempCtx.globalCompositeOperation = 'multiply'; + tempCtx.fillStyle = `#${color}`; + tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); + + // Reset composite operation and preserve alpha + tempCtx.globalCompositeOperation = 'destination-in'; + tempCtx.drawImage(imageElement, 0, 0); + + return tempCanvas; + } + + /** + * Calculate character heights and visual positioning. + * This follows the same logic as the Python version's height calculation steps. + */ + calculateCharacterMetrics(characters) { + // Step 1: Calculate scaled heights, adjusting for ears offset if applicable + const heightAdjustedChars = characters.map(char => { + // Apply species scaling if enabled (would call calculate_height_offset equivalent) + let adjustedHeight = char.feral_height; + if (this.options.useSpeciesScaling) { + // TODO: Implement species-specific height adjustments + // This would apply species scaling similar to Python calculate_height_offset + } + + // Calculate visual height by adding ears_offset percentage if applicable + let visualHeight = adjustedHeight; + if (this.options.measureToEars && char.ears_offset) { + // Increase height by a percentage factor so the top of the character appears taller + visualHeight = adjustedHeight * (1 + char.ears_offset / 100.0); + } else { + // Default to actual character height if not measuring to ears + visualHeight = adjustedHeight; + } + + return { + ...char, + adjustedHeight, + visualHeight + }; + }); + + // Step 2: Determine the render height based on the tallest character's visual height + const tallestHeight = Math.max(...heightAdjustedChars.map(c => c.visualHeight)); + const renderHeight = tallestHeight * 1.05; // Add 5% padding + + // Decide line granularity based on height (matches Python logic) + const drawLineAtFoot = renderHeight > 22; + + // Step 3: Calculate scale factors based on render height + const scaleFactors = heightAdjustedChars.map(char => + char.visualHeight / renderHeight + ); + + return { + characters: heightAdjustedChars, + renderHeight, + scaleFactors, + drawLineAtFoot + }; + } + + /** + * Calculate canvas dimensions and character positioning + */ + async calculateLayout(characters) { + const { fontSize, charPadding } = this.calculateDynamicSizes(); + const metrics = this.calculateCharacterMetrics(characters); + + let totalWidth = 0; + const characterDimensions = []; + + for (let i = 0; i < characters.length; i++) { + const char = metrics.characters[i]; + const scaleFactor = metrics.scaleFactors[i]; + + // Load image to get dimensions + // Flask serves species_data as static files, but they're actually in app/species_data + const img = await this.loadCharacterImage(`/species_data/${char.image}`); + + // Calculate scaled dimensions + const charImgHeight = Math.floor(this.options.size * scaleFactor); + const charImgWidth = Math.floor(img.width * (charImgHeight / img.height)); + + characterDimensions.push({ + width: charImgWidth, + height: charImgHeight, + image: img + }); + + totalWidth += charImgWidth + charPadding; + } + + // Remove the last padding + totalWidth -= charPadding; + + const bottomPadding = Math.floor(this.options.size / 10); + const canvasHeight = this.options.size + bottomPadding; + + return { + width: totalWidth, + height: canvasHeight, + metrics, + characterDimensions, + fontSize, + charPadding, + bottomPadding + }; + } + + /** + * Draw grid lines + */ + drawGrid(layout) { + const { width, height, metrics } = layout; + + this.ctx.strokeStyle = this.options.gridColor; + this.ctx.lineWidth = this.options.gridLineWidth; + + if (metrics.drawLineAtFoot) { + // Draw lines every foot + for (let foot = 0; foot <= Math.floor(metrics.renderHeight / 12); foot++) { + const y = this.options.size - Math.floor((foot * 12) / metrics.renderHeight * this.options.size); + this.ctx.beginPath(); + this.ctx.moveTo(0, y); + this.ctx.lineTo(width, y); + this.ctx.stroke(); + } + } else { + // Draw lines every inch + for (let inch = 0; inch <= Math.floor(metrics.renderHeight); inch++) { + const y = this.options.size - Math.floor(inch / metrics.renderHeight * this.options.size); + this.ctx.beginPath(); + this.ctx.moveTo(0, y); + this.ctx.lineTo(width, y); + this.ctx.stroke(); + } + } + } + + /** + * Draw dotted line for height indicators + */ + drawDottedLine(startX, endX, y, color) { + const dashLength = Math.floor((40 * this.options.size) / 1024); + const gap = Math.floor((20 * this.options.size) / 1024); + const lineWidth = Math.floor((6 * this.options.size) / 1024); + + this.ctx.strokeStyle = color; + this.ctx.lineWidth = lineWidth; + this.ctx.setLineDash([dashLength, gap]); + + this.ctx.beginPath(); + this.ctx.moveTo(startX, y); + this.ctx.lineTo(endX, y); + this.ctx.stroke(); + + this.ctx.setLineDash([]); // Reset line dash + } + + /** + * Get dominant color from image (simplified version) + */ + getDominantColor(imageElement) { + // Create a 1x1 canvas to get average color + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + tempCanvas.width = 1; + tempCanvas.height = 1; + + tempCtx.drawImage(imageElement, 0, 0, 1, 1); + const pixelData = tempCtx.getImageData(0, 0, 1, 1).data; + + return `rgb(${pixelData[0]}, ${pixelData[1]}, ${pixelData[2]})`; + } + + /** + * Convert inches to feet and inches display + */ + inchesToFeetInches(inches) { + const feet = Math.floor(inches / 12); + const remainingInches = Math.round(inches % 12); + return `${feet}'${remainingInches}"`; + } + + /** + * Main render function + */ + async render(characters) { + if (!this.canvas || !this.ctx) { + throw new Error('Renderer not initialized'); + } + + const layout = await this.calculateLayout(characters); + this.lastLayout = layout; // Store for subclasses to access + + // Set canvas size + this.canvas.width = layout.width; + this.canvas.height = layout.height; + + // Clear canvas with background color + this.ctx.fillStyle = this.options.backgroundColor; + this.ctx.fillRect(0, 0, layout.width, layout.height); + + // Draw grid + this.drawGrid(layout); + + // Draw characters + let xOffset = 0; + + for (let i = 0; i < characters.length; i++) { + const char = layout.metrics.characters[i]; + const dimensions = layout.characterDimensions[i]; + + // Apply color tint if specified + let characterImage = dimensions.image; + if (char.color) { + characterImage = this.applyColorTint(characterImage, char.color); + } + + // Calculate y position + const yOffset = this.options.size - dimensions.height; + + // Draw character image + this.ctx.drawImage(characterImage, xOffset, yOffset, dimensions.width, dimensions.height); + + // Draw character text + const textX = xOffset + Math.floor(1.1 * dimensions.width); + const textY = yOffset + Math.floor(0.1 * dimensions.height); + const dominantColor = this.getDominantColor(characterImage); + + // Draw height indicator line if measuring to ears + if (this.options.measureToEars && char.ears_offset) { + const heightLineY = this.options.size - Math.floor((char.adjustedHeight / layout.metrics.renderHeight) * this.options.size); + this.drawDottedLine(xOffset, xOffset + dimensions.width, heightLineY, dominantColor); + } + + this.ctx.fillStyle = dominantColor; + this.ctx.font = `${layout.fontSize}px ${this.options.fontFamily}`; + + // Character name + this.ctx.fillText(char.name, textX, textY - (layout.fontSize + 5)); + + // Height and species info + const heightText = this.inchesToFeetInches(char.adjustedHeight); + const speciesText = char.species.replace('_', ' '); + const fullText = `${heightText}\n${speciesText}`; + + // Draw multi-line text + const lines = fullText.split('\n'); + lines.forEach((line, lineIndex) => { + this.ctx.fillText(line, textX, textY + (lineIndex * (layout.fontSize + 2))); + }); + + xOffset += dimensions.width + layout.charPadding; + } + + return this.canvas; + } + + /** + * Export canvas as blob (client-side only) + */ + async exportAsBlob(type = 'image/png', quality = 0.92) { + if (!this.isClientSide) { + throw new Error('exportAsBlob only available client-side'); + } + + return new Promise(resolve => { + this.canvas.toBlob(resolve, type, quality); + }); + } + + /** + * Export canvas as data URL (client-side only) + */ + exportAsDataURL(type = 'image/png', quality = 0.92) { + if (!this.isClientSide) { + throw new Error('exportAsDataURL only available client-side'); + } + + return this.canvas.toDataURL(type, quality); + } +} + +// Export for both browser and Node.js environments +if (typeof module !== 'undefined' && module.exports) { + module.exports = UniversalRenderer; +} else if (typeof window !== 'undefined') { + window.UniversalRenderer = UniversalRenderer; +} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index e563cb8..59c5f73 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -7,6 +7,7 @@ Vixi's Anthro Size Diff Calculator! + @@ -36,6 +37,18 @@

Vixi's Anthro Size Diff Calculator!

+ + + {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -287,23 +300,68 @@

Vixi's Anthro Size Diff Calculator!

{% if characters_list %} -
- Generated Size Image - -
- {% for char in characters_list %} - - Remove {{ char["name"] }} - - {% endfor %} + +
+
+ +
+ + +
+
+

Characters (click to edit, drag to reorder)

+
+ +
+
+ +
+

Edit Selected Character

+
Click a character to edit its properties
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ Show Static Image (legacy) + Generated Size Image + +
+ {% for char in characters_list %} + + Remove {{ char["name"] }} + + {% endfor %} +
+
+
+