3 Commits

Author SHA1 Message Date
6872056901 working on 3d cards 2025-08-06 16:26:26 +02:00
7c24d9a7c5 fix mobile redirect 2025-08-04 17:24:39 +02:00
deb43a47f7 add mobile scanner 2025-08-04 17:15:39 +02:00
5 changed files with 280 additions and 36 deletions

View File

@ -13,6 +13,13 @@ from typing import List, Dict, Any
import pandas as pd import pandas as pd
import re import re
from enum import Enum from enum import Enum
from solid import *
from solid.utils import *
import numpy as np
import trimesh
import cairosvg
MAX_ALBUM_IMG_SIZE = 512 MAX_ALBUM_IMG_SIZE = 512
@ -238,6 +245,165 @@ class HipsterfyPlaylistItem(object):
</div> </div>
""" """
def create_text_mesh_svg(self, text, font_size=100, extrude_height=1.0, font_family="Arial"):
# Define a rough bounding box size (oversized is OK, it will crop to path later)
svg_width = font_size * len(text) * 0.6 # heuristic width
svg_height = font_size * 1.5 # little padding below
# 1. Create SVG with size attributes!
svg_template = f'''<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{svg_width}px" height="{svg_height}px" viewBox="0 0 {svg_width} {svg_height}">
<text x="0" y="{font_size}" font-family="{font_family}" font-size="{font_size}px">{text}</text>
</svg>'''
# 2. Use CairoSVG to convert to SVG with <path> outlines (in-memory)
outlined_svg = BytesIO()
cairosvg.svg2svg(bytestring=svg_template.encode('utf-8'), write_to=outlined_svg)
outlined_svg.seek(0)
# 3. Load the SVG Path into Trimesh
path = trimesh.load_path(outlined_svg, "svg")
# 4. Get closed polygons
polygons = path.polygons_full
if len(polygons) == 0:
raise ValueError("No polygons found in SVG. Make sure the font is available.")
# 5. Extrude into 3D meshes
meshes = []
for p in polygons:
mesh = trimesh.creation.extrude_polygon(p, height=extrude_height)
# Fix potential non-watertight edges
mesh = mesh.copy().process(validate=True)
if not mesh.is_watertight:
print("Warning: Mesh is not watertight. Trying to fix...")
mesh = mesh.convex_hull # Hack: force watertight by convex hull
meshes.append(mesh)
text_mesh = trimesh.util.concatenate(meshes)
text_mesh.apply_translation(-text_mesh.centroid)
# Final safety net
if not text_mesh.is_watertight:
print("Final Warning: Text mesh still not watertight after fix.")
return text_mesh
def generate_3d_qr_card(
self,
primary_property,
secondary_property,
additional_properties: List[str] = None,
card_size=60,
card_thickness=3,
qr_relief_depth=0.5, # Tiefe der Vertiefung
qr_margin=8
):
"""
Generates a 3D card (STL as bytes) with a sunken QR code (engraved).
Returns: STL file as bytes.
"""
qr = qrcode.QRCode(box_size=1, border=0)
qr.add_data(self.link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert("1")
arr = np.array(img)
h, w = arr.shape
pixel_size = (card_size - 2 * qr_margin) / w
# Grundplatte
base = trimesh.creation.box(extents=(card_size, card_size, card_thickness))
base.apply_translation((card_size/2, card_size/2, card_thickness/2))
# QR-Vertiefung (nur die weißen Felder bleiben stehen, schwarze werden "eingesunken")
cutouts_xa_ya = []
cutouts_xa_yb = []
cutouts_xb_ya = []
cutouts_xb_yb = []
for y in range(h):
for x in range(w):
if arr[y, x] == 0: # Schwarzes Pixel → wird vertieft
px = qr_margin + x * pixel_size + pixel_size/2
py = qr_margin + (h - y - 1) * pixel_size + pixel_size/2
cube = trimesh.creation.box(extents=(pixel_size * 1.00001, pixel_size * 1.00001, qr_relief_depth))
cube.apply_translation((px, py, card_thickness - qr_relief_depth/2))
if y % 2 == 0:
if x % 2 == 0:
cutouts_xa_ya.append(cube)
else:
cutouts_xa_yb.append(cube)
else:
if x % 2 == 0:
cutouts_xb_ya.append(cube)
else:
cutouts_xb_yb.append(cube)
card = base
if cutouts_xa_ya:
cutout_xa_ya = trimesh.util.concatenate(cutouts_xa_ya)
card = card.difference(cutout_xa_ya)
if cutouts_xa_yb:
cutout_xa_yb = trimesh.util.concatenate(cutouts_xa_yb)
card = card.difference(cutout_xa_yb)
if cutouts_xb_ya:
cutout_xb_ya = trimesh.util.concatenate(cutouts_xb_ya)
card = card.difference(cutout_xb_ya)
if cutouts_xb_yb:
cutout_xb_yb = trimesh.util.concatenate(cutouts_xb_yb)
card = card.difference(cutout_xb_yb)
# engrave the text to the lower card side
lines = []
if primary_property:
lines.append(str(getattr(self, primary_property, "")))
if secondary_property:
lines.append(str(getattr(self, secondary_property, "")))
if additional_properties:
for prop in additional_properties:
value = getattr(self, prop, "")
if value:
lines.append(f"{prop.replace('_', ' ').title()}: {value}")
text_depth = qr_relief_depth
text_size = card_size * 0.09 # Schriftgröße relativ zur Karte
# Combine into one mesh
text_mesh = self.create_text_mesh_svg("\n".join(lines), font_size=text_size, extrude_height=text_depth, font_family="Arial")
# Center the text mesh
text_mesh.apply_translation((-text_mesh.centroid[0], -text_mesh.centroid[1], 0))
# Position the text mesh on the card
text_mesh.apply_translation((card_size / 2, card_size / 2, card_thickness - text_depth / 2))
# Text von der Karte abziehen (gravieren)
card = card.difference(text_mesh, check_volume=True)
## round corners
radius = 5
corners = [
(radius, radius, 0),
(card_size - radius, radius, 0),
(card_size - radius, card_size - radius, 0),
(radius, card_size - radius, 0)
]
plate_offsets = [
(radius / 2, radius / 2, 0),
(card_size - radius / 2, radius / 2, 0),
(card_size - radius / 2, card_size - radius / 2, 0),
(radius / 2, card_size - radius / 2, 0)
]
for i, corner in enumerate(corners ):
plate = trimesh.creation.box(extents=(radius, radius, card_thickness))
plate.apply_translation((plate_offsets[i][0], plate_offsets[i][1], card_thickness / 2))
cylinder = trimesh.creation.cylinder(radius=radius, height=card_thickness, sections=64)
cylinder.apply_translation((corner[0], corner[1], corner[2] + card_thickness / 2))
inverse_corner = plate.difference(cylinder)
card = card.difference(inverse_corner, check_volume=True)
return card.export(file_type='stl')
@staticmethod @staticmethod
def get_available_properties() -> List[str]: def get_available_properties() -> List[str]:
""" """
@ -416,3 +582,4 @@ class HipsterfyPlaylist(object):
"""Returns the list of HipsterfyPlaylistItem objects representing the tracks in the playlist. """Returns the list of HipsterfyPlaylistItem objects representing the tracks in the playlist.
""" """
return self._tracks_data return self._tracks_data

View File

@ -11,13 +11,14 @@ def parse_args():
parser.add_argument('--spotify_client_id', type=str, required=True, help='Spotify Client ID') parser.add_argument('--spotify_client_id', type=str, required=True, help='Spotify Client ID')
parser.add_argument('--spotify_client_secret', type=str, required=True, help='Spotify Client Secret') parser.add_argument('--spotify_client_secret', type=str, required=True, help='Spotify Client Secret')
parser.add_argument('--port', type=int, default=5006, help='Port to run the Panel app on') parser.add_argument('--port', type=int, default=5006, help='Port to run the Panel app on')
parser.add_argument('--root_url', type=str, default=None, help='Root URL for the Panel app (optional)')
return parser.parse_args() return parser.parse_args()
def panel_main(): def panel_main():
args = parse_args() args = parse_args()
hipsterfy = Hipsterfy(args.spotify_client_id, args.spotify_client_secret) hipsterfy = Hipsterfy(args.spotify_client_id, args.spotify_client_secret)
playlist_uri = 'https://open.spotify.com/playlist/5grJs3PKyLE0cL5NYmkGIF' playlist_uri = 'https://open.spotify.com/playlist/5grJs3PKyLE0cL5NYmkGIF'
app = lambda: create_panel_page(hipsterfy, playlist_uri) app = lambda: create_panel_page(hipsterfy, playlist_uri, args.root_url)
static_dir = os.path.join(os.path.dirname(__file__), "static") static_dir = os.path.join(os.path.dirname(__file__), "static")
pn.serve( pn.serve(

View File

@ -3,13 +3,14 @@ import base64
from io import BytesIO from io import BytesIO
from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle from hipsterfy.hipsterfy import Hipsterfy, HipsterfyPlaylist, HipsterfyPlaylistItem, CardStyle
import qrcode import qrcode
import traceback
import subprocess
pn.extension("filedownload", "notifications", "location") pn.extension("filedownload", "notifications", "location")
def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None, root_url = None) -> pn.Template:
def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Template:
# create widgets # create widgets
playlist_uri = pn.widgets.TextInput(name='Playlist URI', value=playlist_uri or '', placeholder='Enter Spotify Playlist URI') playlist_uri = pn.widgets.TextInput(name='Playlist URI', value=playlist_uri or '', placeholder='Enter Spotify Playlist URI')
@ -18,6 +19,23 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
additional_items = pn.widgets.MultiChoice(name='Additional Items', options=HipsterfyPlaylistItem.get_available_properties(), value=["artists", "album", "popularity"]) additional_items = pn.widgets.MultiChoice(name='Additional Items', options=HipsterfyPlaylistItem.get_available_properties(), value=["artists", "album", "popularity"])
card_style = pn.widgets.Select(name='Card Style', options=[style.value for style in CardStyle], value=CardStyle.FULL_ALBUM_COVER.value) card_style = pn.widgets.Select(name='Card Style', options=[style.value for style in CardStyle], value=CardStyle.FULL_ALBUM_COVER.value)
cards_per_row_widget = pn.widgets.IntSlider(name="Cards per Row in print", start=1, end=8, value=4, step=1) cards_per_row_widget = pn.widgets.IntSlider(name="Cards per Row in print", start=1, end=8, value=4, step=1)
mode_selector = pn.widgets.RadioButtonGroup(
name="Card Generation Mode (2D for printing or 3D to produce stl files for 3D printing)",
options=["2D", "3D"],
value="2D",
button_type="primary",
button_style="outline",
sizing_mode='stretch_width'
)
# add a callback that hides the card style widget if 3D mode is selected
def toggle_card_style_visibility(event):
if mode_selector.value == "3D":
card_style.visible = False
else:
card_style.visible = True
mode_selector.param.watch(toggle_card_style_visibility, 'value')
pn.state.location.sync(playlist_uri, {"value": "playlist_uri"}) pn.state.location.sync(playlist_uri, {"value": "playlist_uri"})
pn.state.location.sync(primary_item, {"value": "primary_item"}) pn.state.location.sync(primary_item, {"value": "primary_item"})
@ -25,6 +43,7 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
pn.state.location.sync(additional_items, {"value": "additional_items"}) pn.state.location.sync(additional_items, {"value": "additional_items"})
pn.state.location.sync(card_style, {"value": "card_style"}) pn.state.location.sync(card_style, {"value": "card_style"})
pn.state.location.sync(cards_per_row_widget, {"value": "cards_per_row"}) pn.state.location.sync(cards_per_row_widget, {"value": "cards_per_row"})
pn.state.location.sync(mode_selector, {"value": "mode_selector"})
create_preview_button = pn.widgets.Button(name='Parse Playlist and Generate Cards', button_type='primary', sizing_mode='stretch_width') create_preview_button = pn.widgets.Button(name='Parse Playlist and Generate Cards', button_type='primary', sizing_mode='stretch_width')
@ -110,6 +129,9 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
download_back_html_button.callback = lambda: BytesIO(generate_html(cards_back, row_reverse=True)) download_back_html_button.callback = lambda: BytesIO(generate_html(cards_back, row_reverse=True))
download_back_html_button.visible = True download_back_html_button.visible = True
download_front_html_button.filename = f"hipsterfy_front_cards.html"
download_back_html_button.filename = f"hipsterfy_back_cards.html"
print_instructions.visible = True # Show print instructions after generating preview print_instructions.visible = True # Show print instructions after generating preview
pn.state.notifications.success("Download buttons updated successfully!. Download and print them from the sidebar") pn.state.notifications.success("Download buttons updated successfully!. Download and print them from the sidebar")
@ -121,47 +143,77 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
# clear previous cards # clear previous cards
front_cards_column.clear() front_cards_column.clear()
back_cards_column.clear() back_cards_column.clear()
front_cards_column.loading = True front_cards_column.loading = True
back_cards_column.loading = True back_cards_column.loading = True
front_cards_column.append(pn.pane.HTML("<h2>Front Cards</h2>", sizing_mode='stretch_width')) front_cards_column.append(pn.pane.HTML("<h2>Front Cards</h2>", sizing_mode='stretch_width'))
back_cards_column.append(pn.pane.HTML("<h2>Back Cards</h2>", sizing_mode='stretch_width')) back_cards_column.append(pn.pane.HTML("<h2>Back Cards</h2>", sizing_mode='stretch_width'))
# get playlist URI
uri = playlist_uri.value.strip() uri = playlist_uri.value.strip()
if not uri: if not uri:
pn.state.notifications.error("Please enter a valid Spotify Playlist URI.") pn.state.notifications.error("Please enter a valid Spotify Playlist URI.")
return return
# create HipsterfyPlaylist instance
hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy) hipsterfy_playlist = HipsterfyPlaylist(uri, hipsterfy)
new_back_cards = [] new_back_cards = []
new_front_cards = [] new_front_cards = []
# generate front and back cards for each item in the playlist if mode_selector.value == "2D":
for item in hipsterfy_playlist.get_tracks_data(): # 2D-HTML wie gehabt
front_card = item.generate_hipsterfy_front_card(include_preview=False, card_style=CardStyle(card_style.value)) for item in hipsterfy_playlist.get_tracks_data():
back_card = item.generate_hipsterfy_back_card( front_card = item.generate_hipsterfy_front_card(include_preview=False, card_style=CardStyle(card_style.value))
primary_property=primary_item.value, back_card = item.generate_hipsterfy_back_card(
secondary_property=secondary_item.value, primary_property=primary_item.value,
additional_properties=additional_items.value, secondary_property=secondary_item.value,
card_style=CardStyle(card_style.value) additional_properties=additional_items.value,
) card_style=CardStyle(card_style.value)
new_front_cards.append(pn.pane.HTML(front_card, sizing_mode='stretch_width')) )
new_back_cards.append(pn.pane.HTML(back_card, sizing_mode='stretch_width')) new_front_cards.append(pn.pane.HTML(front_card, sizing_mode='stretch_width'))
new_back_cards.append(pn.pane.HTML(back_card, sizing_mode='stretch_width'))
for card in new_front_cards: for card in new_front_cards:
front_cards_column.append(card) front_cards_column.append(card)
for card in new_back_cards: for card in new_back_cards:
back_cards_column.append(card) back_cards_column.append(card)
update_download_buttons()
pn.state.notifications.success("Preview generated successfully!") pn.state.notifications.success("2D preview generated successfully!")
pn.state.notifications.info("Prepare to download the cards as HTML files...") else:
update_download_buttons() stl_buffers = []
for idx, item in enumerate(hipsterfy_playlist.get_tracks_data()):
stl_bytes = item.generate_3d_qr_card(
primary_property=primary_item.value,
secondary_property=secondary_item.value,
additional_properties=additional_items.value,
)
stl_filename = f"hipsterfy_card_{idx+1}.stl"
stl_buffers.append((stl_filename, stl_bytes))
front_cards_column.append(
pn.pane.Markdown(f"**3D Card {idx+1}:** `{stl_filename}` generated.", sizing_mode='stretch_width')
)
import zipfile
def zip_stls():
try:
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
for fname, stl_bytes in stl_buffers:
zf.writestr(fname, stl_bytes)
buffer.seek(0)
return buffer
except Exception as e:
print("Error creating ZIP file:", e)
traceback.print_exc()
pn.state.notifications.error(f"Error creating ZIP file: {str(e)}", duration=10000)
return None
download_front_html_button.label = "Download 3D Cards (ZIP)"
download_front_html_button.callback = zip_stls
download_front_html_button.visible = True
download_back_html_button.visible = False
download_front_html_button.filename = "hipsterfy_3d_cards.zip"
pn.state.notifications.success("3D STL files generated! Download the ZIP from the sidebar.")
print_instructions.visible = mode_selector.value == "2d"
except Exception as e: except Exception as e:
pn.state.notifications.error(f"Error generating preview: {str(e)}") print("Error:", e)
traceback.print_exc()
pn.state.notifications.error(f"Error generating preview: {str(e)}", duration=10000)
finally: finally:
front_cards_column.loading = False front_cards_column.loading = False
back_cards_column.loading = False back_cards_column.loading = False
@ -182,11 +234,14 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
# Dynamisch: QR-Code aktualisiert sich, wenn die Seite unter einer anderen URL läuft # Dynamisch: QR-Code aktualisiert sich, wenn die Seite unter einer anderen URL läuft
def player_qr_html(): def player_qr_html():
# pn.state.location.href ist erst nach dem ersten Request gesetzt # pn.state.location.href ist erst nach dem ersten Request gesetzt
base_url = pn.state.location.href or main_url if root_url is None:
# Basis-URL ohne evtl. Pfad/Query base_url = pn.state.location.href
from urllib.parse import urlparse # Basis-URL ohne evtl. Pfad/Query
parsed = urlparse(base_url) from urllib.parse import urlparse
base = f"{parsed.scheme}://{parsed.netloc}" parsed = urlparse(base_url)
base = f"{parsed.scheme}://{parsed.netloc}"
else:
base = root_url
url = base + "/qr/scan.html" url = base + "/qr/scan.html"
return generate_qr_code_base64(url) return generate_qr_code_base64(url)
@ -195,7 +250,16 @@ def create_panel_page(hipsterfy: Hipsterfy, playlist_uri: str=None) -> pn.Templa
template = pn.template.FastListTemplate( template = pn.template.FastListTemplate(
title='Hipsterfy', title='Hipsterfy',
sidebar=[ sidebar=[
player_qr_pane, # <-- Dynamischer QR-Code ganz oben in der Sidebar player_qr_pane,
pn.pane.HTML("<h3> Select card generation mode:</h3>", sizing_mode='stretch_width'),
pn.pane.HTML(
"""
<p>In 2D mode, you can print the cards directly from your browser. In 3D mode, STL files will be generated for 3d printing will be generated.</p>
""",
sizing_mode='stretch_width'
),
mode_selector,
pn.layout.Divider(),
playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style, playlist_instructions, playlist_uri, primary_item, secondary_item, additional_items, card_style,
cards_per_row_widget, cards_per_row_widget,
create_preview_button, download_front_html_button, download_back_html_button, print_instructions create_preview_button, download_front_html_button, download_back_html_button, print_instructions

View File

@ -110,7 +110,7 @@
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<h2>Spotify QR-Scanner</h2> <h2>Hipsterfy QR-Scanner</h2>
<video id="video" autoplay playsinline></video> <video id="video" autoplay playsinline></video>
<div id="result"></div> <div id="result"></div>
<button id="backBtn">Back to scanner</button> <button id="backBtn">Back to scanner</button>

View File

@ -1,6 +1,6 @@
[project] [project]
name = "hipsterfy" name = "hipsterfy"
version = "0.2.3" version = "0.3.2"
description = "" description = ""
authors = [ authors = [
{name="Jonas Weinz"} {name="Jonas Weinz"}
@ -13,7 +13,19 @@ dependencies = [
"pillow", "pillow",
"qrcode", "qrcode",
"panel", "panel",
"numpy",
"opencv-python",
"trimesh",
"manifold3d",
"lxml",
"svg.path",
"shapely",
"cairosvg",
"networkx",
"rtree",
"scikit-learn",
] ]
[tool.poetry] [tool.poetry]
name = "hipsterfy" name = "hipsterfy"
version = "0.2.3" version = "0.2.3"