diff --git a/.gitignore b/.gitignore index fed7c46..d75f6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ build/ *.log __pycache__/ *.pyc +.claude/ +.planning/ +tools/ diff --git a/.htaccess b/.htaccess index b34eefa..34d5d87 100644 --- a/.htaccess +++ b/.htaccess @@ -1,14 +1,36 @@ Options -Indexes RewriteEngine On -# Deny sensitive files - +# Security headers + + Header always set X-Frame-Options "SAMEORIGIN" + Header always set X-Content-Type-Options "nosniff" + Header always set X-XSS-Protection "1; mode=block" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-ancestors 'none';" + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" + + +# Block server version +ServerSignature Off + +# Deny sensitive file types + Order allow,deny Deny from all -# Deny tools directory +# Block root-level dev files + + Order allow,deny + Deny from all + + +# Block tools and hidden directories RewriteRule ^tools/ - [F,L] +RewriteRule ^\.planning/ - [F,L] +RewriteRule ^\.claude/ - [F,L] ErrorDocument 404 /404.html ErrorDocument 500 /500.html diff --git a/.planning/SITEMAP_CANONICAL.md b/.planning/SITEMAP_CANONICAL.md deleted file mode 100644 index 4079eae..0000000 --- a/.planning/SITEMAP_CANONICAL.md +++ /dev/null @@ -1,124 +0,0 @@ -# SITEMAP_CANONICAL.md — lahrcarpetcleaning.com - -## Nav Structure -Home · Residential · Commercial · Our Work · About · Company - ---- - -## Pages - -### / — Home -Status: EXISTS -File: index.html - ---- - -### /residential/ — Residential Services (landing) -Status: TODO — new page -Nav: Residential (parent) -Purpose: Overview of all residential services with cards linking to each sub-page. - -#### /residential/carpet-cleaning/ -Status: EXISTS (currently at /services/carpet-cleaning/) — needs folder rename -Hero image: hero-technician.jpg - -#### /residential/stairs/ -Status: EXISTS (currently at /services/stairs/) — needs folder rename -Hero image: hero-stairs.jpg - -#### /residential/upholstery/ -Status: EXISTS (currently at /services/upholstery/) — needs folder rename -Hero image: hero-clean-result.jpg - -#### /residential/floors/ -Status: EXISTS (currently at /services/floors/) — needs folder rename -Hero image: hero-living-room.jpg - -#### /residential/area-rugs/ -Status: EXISTS (currently at /services/area-rugs/) — needs folder rename -Hero image: hero-clean-result.jpg - -#### /residential/add-ons/ -Status: EXISTS (currently at /services/add-ons/) — needs folder rename -Hero image: hero-before-after.jpg - ---- - -### /commercial/ — Commercial Cleaning -Status: EXISTS (currently at /services/commercial/) — needs folder move to root -Hero image: AdobeStock commercial images (keep as-is) - ---- - -### /our-work/ — Our Work (Before/After Gallery) -Status: TODO — new page -Nav: Our Work (standalone) -Purpose: Grid of before/after job photos. Real client work images. -Images: assets/images/our-work/ (folder needed) - ---- - -### /about/ — About -Status: EXISTS -Nav: About (standalone) - ---- - -### /company/ — Company (dropdown parent, no landing needed) -Nav label: Company - -#### /contact/ — Contact -Status: EXISTS - -#### /service-area/ — Service Area -Status: TODO — new page -Purpose: Towns served — Waterloo, Seneca Falls, Geneva, Canandaigua, Auburn, Finger Lakes region. -SEO: local landing content per town area. - -#### /reviews/ — Reviews -Status: TODO — new page -Purpose: Google review embeds / static testimonials. - ---- - -## Folder Rename Plan - -| Current path | New path | -|----------------------------|-------------------------------| -| /services/carpet-cleaning/ | /residential/carpet-cleaning/ | -| /services/stairs/ | /residential/stairs/ | -| /services/upholstery/ | /residential/upholstery/ | -| /services/floors/ | /residential/floors/ | -| /services/area-rugs/ | /residential/area-rugs/ | -| /services/add-ons/ | /residential/add-ons/ | -| /services/commercial/ | /commercial/ | - -All internal nav links and footer links update site-wide on rename. - ---- - -## sitemap.xml URLs (target) - -/ -/residential/ -/residential/carpet-cleaning/ -/residential/stairs/ -/residential/upholstery/ -/residential/floors/ -/residential/area-rugs/ -/residential/add-ons/ -/commercial/ -/our-work/ -/about/ -/contact/ -/service-area/ -/reviews/ - ---- - -## Notes -- No pricing on any page (pricing flyer is internal reference only) -- All hero images: Imagen 4 generated (assets/images/hero/) -- Service area focus: Upstate NY — Waterloo, Seneca Falls, Geneva, Finger Lakes region -- Phone: 315-719-1218 | Email: lahrcarpet@gmail.com -- Address: 1076 Waterloo/Geneva Road, Waterloo, NY diff --git a/.planning/lahr-carpet-cleaning.jpeg b/.planning/lahr-carpet-cleaning.jpeg deleted file mode 100644 index f25662b..0000000 Binary files a/.planning/lahr-carpet-cleaning.jpeg and /dev/null differ diff --git a/infra/nginx.conf b/infra/nginx.conf index bb8bc79..0f64eaf 100644 --- a/infra/nginx.conf +++ b/infra/nginx.conf @@ -4,6 +4,42 @@ server { root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-ancestors 'none';" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Remove server version fingerprint + server_tokens off; + + # Block dot files and hidden directories + location ~ /\. { + deny all; + return 404; + } + + # Block tools directory + location ^~ /tools/ { + deny all; + return 404; + } + + # Block sensitive file types + location ~* \.(env|log|sh|py|pyc|md|yml|yaml|conf|dockerfile|dockerignore|bak|backup|sql|key|pem|json|planning|cpanel)$ { + deny all; + return 404; + } + + # Block README and Dockerfile at root + location ~* ^/(README|Dockerfile|docker-compose\.yml|\.cpanel\.yml)$ { + deny all; + return 404; + } + location / { try_files $uri $uri/ $uri/index.html =404; } @@ -11,16 +47,8 @@ server { error_page 404 /404.html; error_page 500 502 503 504 /500.html; - location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf)$ { + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|webp|woff|woff2|ttf|mp4|webm)$ { expires 30d; add_header Cache-Control "public, immutable"; } - - location ~ /\. { - deny all; - } - - location ~* \.(env|Dockerfile|dockerignore|yml|yaml|md|planning)$ { - deny all; - } } diff --git a/tools/build-paced-reel.py b/tools/build-paced-reel.py deleted file mode 100644 index 025e7eb..0000000 --- a/tools/build-paced-reel.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Build paced hero reel from the browser-ordered clip list. -- Shot 1 (family entry): trimmed to 3s — cuts before mud pan -- Shots 2-7: full 6s (establish the story) -- Shots 8-12: trimmed to 3.5s (building pace) -- Shots 13-15: trimmed to 2.5s (rapid) -- Shot 16 (final reveal): full 6s (hold on clean result) -""" -import os, subprocess, shutil - -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -VID_DIR = os.path.join(BASE_DIR, "assets", "videos", "hero", "clips") -REEL_OUT = os.path.join(BASE_DIR, "assets", "videos", "hero", "hero-reel.mp4") -TMP_DIR = os.path.join(VID_DIR, "paced-tmp") -os.makedirs(TMP_DIR, exist_ok=True) - -# Clip order: wine spill > extraction > stain > technician, stairs later -CLIPS = [ - "v3-shot-02", # 1 - wine spill on sofa - "shot-05-extraction-couch",# 2 - extraction couch - "v3-shot-06", # 4 - living room clean carpet pan - "v3-shot-07", # 5 - restaurant carpet glide - "v3-shot-05", # 6 - office lobby carpet pan - "v2-shot-05-clean-stairs", # 7 - clean bright staircase - "v2-shot-07-restaurant", # 8 - restaurant carpet - "v2-shot-06-office", # 9 - bright office carpet - "shot-01-wide-room", # 10 - wide room establishing - "shot-05-clean-reveal", # 11 - clean reveal - "shot-04-extraction-carpet",# 12 - final reveal -] - -# Duration for each clip -DURATIONS = [ - 3.0, # 1 wine spill — shortened to 3.0s - 5.0, # 2 - 4.0, # 4 --- building pace --- - 4.0, # 5 - 3.5, # 6 - 3.5, # 7 - 2.5, # 8 --- rapid --- - 2.5, # 9 - 2.5, # 10 - 2.5, # 11 - 6.0, # 12 final reveal — hold -] - -paced_clips = [] -for i, (name, dur) in enumerate(zip(CLIPS, DURATIONS)): - src = os.path.join(VID_DIR, f"{name}.mp4") - out = os.path.join(TMP_DIR, f"{i:02d}-{name}.mp4") - if not os.path.exists(src): - print(f" MISSING: {src}") - continue - print(f"[{i+1:02d}] {name} → {dur}s") - result = subprocess.run( - ["ffmpeg", "-y", "-i", src, "-t", str(dur), - "-c:v", "libx264", "-crf", "22", "-preset", "fast", out], - capture_output=True, text=True - ) - if result.returncode != 0: - print(f" ffmpeg error: {result.stderr[-200:]}") - else: - paced_clips.append(out) - -print(f"\n{len(paced_clips)}/{len(CLIPS)} clips trimmed") - -concat_file = os.path.join(TMP_DIR, "concat.txt") -with open(concat_file, "w") as f: - for p in paced_clips: - f.write(f"file '{p}'\n") - -result = subprocess.run( - ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file, - "-c:v", "libx264", "-crf", "22", "-preset", "fast", "-movflags", "+faststart", REEL_OUT], - capture_output=True, text=True -) -if result.returncode == 0: - print(f"Reel saved: {REEL_OUT} ({os.path.getsize(REEL_OUT)//1024}KB)") - # Clean up tmp - shutil.rmtree(TMP_DIR) - print("Done.") -else: - print(f"ffmpeg error: {result.stderr[-400:]}") diff --git a/tools/build-reel-server.py b/tools/build-reel-server.py deleted file mode 100644 index 5f8736e..0000000 --- a/tools/build-reel-server.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Local server: serves clip-browser.html + handles POST /build-reel to run ffmpeg.""" -import http.server, json, os, subprocess, urllib.parse - -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -VID_DIR = os.path.join(BASE_DIR, "assets", "videos", "hero", "clips") -REEL_OUT = os.path.join(BASE_DIR, "assets", "videos", "hero", "hero-reel.mp4") -TOOLS_DIR = os.path.dirname(__file__) - -class Handler(http.server.BaseHTTPRequestHandler): - def log_message(self, fmt, *args): pass - - def do_GET(self): - path = self.path.split('?')[0] - if path in ('/', '/tools/clip-browser.html'): - self._serve_file(os.path.join(TOOLS_DIR, 'clip-browser.html'), 'text/html') - elif path.startswith('/assets/videos/'): - rel = path.lstrip('/') - fpath = os.path.join(BASE_DIR, rel) - if os.path.exists(fpath): - self._serve_file(fpath, 'video/mp4') - else: - self._404() - else: - self._404() - - def do_POST(self): - if self.path == '/tools/build-reel-api.py': - length = int(self.headers.get('Content-Length', 0)) - body = json.loads(self.rfile.read(length)) - clips = body.get('clips', []) - if not clips: - self._json({'message': 'No clips provided.'}, 400) - return - concat_file = os.path.join(VID_DIR, 'concat-browser.txt') - missing = [] - with open(concat_file, 'w') as f: - for name in clips: - p = os.path.join(VID_DIR, f'{name}.mp4') - if not os.path.exists(p): - missing.append(name) - else: - f.write(f"file '{p}'\n") - if missing: - self._json({'message': f'Missing clips: {missing}'}, 400) - return - result = subprocess.run( - ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file, - '-c:v', 'libx264', '-crf', '22', '-preset', 'fast', - '-movflags', '+faststart', REEL_OUT], - capture_output=True, text=True - ) - if result.returncode == 0: - size = os.path.getsize(REEL_OUT) // 1024 - self._json({'message': f'Reel built: hero-reel.mp4 ({size}KB). Now rebuild Docker: docker compose up -d --build'}) - else: - self._json({'message': f'ffmpeg error: {result.stderr[-300:]}'}, 500) - else: - self._404() - - def _serve_file(self, path, ctype): - with open(path, 'rb') as f: - data = f.read() - self.send_response(200) - self.send_header('Content-Type', ctype) - self.send_header('Content-Length', len(data)) - self.end_headers() - self.wfile.write(data) - - def _json(self, obj, code=200): - data = json.dumps(obj).encode() - self.send_response(code) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', len(data)) - self.end_headers() - self.wfile.write(data) - - def _404(self): - self.send_response(404) - self.end_headers() - -if __name__ == '__main__': - port = 8088 - print(f'Clip browser running at http://localhost:{port}/') - http.server.HTTPServer(('', port), Handler).serve_forever() diff --git a/tools/clip-browser.html b/tools/clip-browser.html deleted file mode 100644 index 094b149..0000000 --- a/tools/clip-browser.html +++ /dev/null @@ -1,230 +0,0 @@ - - - - -Lahr Clip Browser - - - - -

Lahr Clip Browser

-

Drag clips to reorder. Click Preview to watch. Remove clips from reel. Build Reel when ready.

- -
-
-
-
-

Available (not in reel)

-
-
-
- -
- - - - diff --git a/tools/convert-to-webp.py b/tools/convert-to-webp.py deleted file mode 100644 index 47d210e..0000000 --- a/tools/convert-to-webp.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Convert all service/hero JPGs to WebP at 60-70KB target, update all HTML refs.""" -import os, glob -from PIL import Image - -BASE = os.path.dirname(os.path.dirname(__file__)) -IMG_DIRS = [ - os.path.join(BASE, "assets", "images", "services"), - os.path.join(BASE, "assets", "images", "hero"), -] - -def convert(jpg_path, max_width=900, quality=78): - # Hero images get larger max_width - if "/hero/" in jpg_path: - max_width = 1400 - quality = 80 - webp_path = jpg_path.rsplit(".", 1)[0] + ".webp" - img = Image.open(jpg_path).convert("RGB") - w, h = img.size - if w > max_width: - img = img.resize((max_width, int(h * max_width / w)), Image.LANCZOS) - img.save(webp_path, "WEBP", quality=quality, method=6) - kb = os.path.getsize(webp_path) // 1024 - orig_kb = os.path.getsize(jpg_path) // 1024 - return webp_path, kb, orig_kb - -converted = [] -for d in IMG_DIRS: - for jpg in glob.glob(os.path.join(d, "*.jpg")): - if any(x in jpg for x in ["gen.log", "regen"]): - continue - webp, kb, orig = convert(jpg) - print(f" {os.path.basename(jpg)} {orig}KB -> {kb}KB") - converted.append((jpg, webp)) - -print(f"\n{len(converted)} images converted") - -# Update all HTML files to reference .webp instead of .jpg (for these image dirs only) -html_files = [] -for root, dirs, files in os.walk(BASE): - dirs[:] = [d for d in dirs if d not in [".git", "tools", "assets/videos"]] - for f in files: - if f.endswith(".html"): - html_files.append(os.path.join(root, f)) - -updated = 0 -for html in html_files: - with open(html, "r") as f: - content = f.read() - new = content - for jpg, webp in converted: - jpg_ref = "/assets/images/" + os.path.relpath(jpg, os.path.join(BASE, "assets", "images")) - webp_ref = "/assets/images/" + os.path.relpath(webp, os.path.join(BASE, "assets", "images")) - new = new.replace(jpg_ref, webp_ref) - if new != content: - with open(html, "w") as f: - f.write(new) - updated += 1 - -print(f"{updated} HTML files updated to .webp refs") -print("Done.") diff --git a/tools/gen-images-flux.py b/tools/gen-images-flux.py deleted file mode 100644 index 26fdbaf..0000000 --- a/tools/gen-images-flux.py +++ /dev/null @@ -1,292 +0,0 @@ -""" -Generate all site images via FLUX.1 Schnell GGUF through ComfyUI. -FLUX Schnell: 4 steps, cfg=1.0, no negative prompt, photorealistic. -Run after ComfyUI restart: python3 tools/gen-images-flux.py -""" -import json, time, urllib.request, os, random, io - -COMFY = "http://localhost:8188" -HERO_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "images", "hero") -SVC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "images", "services") - -IMAGES = [ - # --- HERO IMAGES --- - {"filename": "hero-carpet-cleaning.jpg", "dir": HERO_DIR, "prompt": ( - "low-angle 35mm lens perspective looking across thick plush cream carpet in an upstate New York living room, " - "carpet fibers razor sharp in foreground, couch and coffee table receding into shallow bokeh background, " - "warm afternoon window light raking across carpet texture, Finger Lakes farmhouse interior, " - "no people, ultra-realistic architectural photography, 16:9" - )}, - {"filename": "hero-stairs.jpg", "dir": HERO_DIR, "prompt": ( - "dramatic low 35mm angle looking up a clean carpeted staircase from floor level, " - "light grey carpet runner sharp and textured in foreground steps, wood banister receding diagonally, " - "bright daylight flooding from above, shallow depth of field, " - "no people, ultra-realistic interior photography, 16:9" - )}, - {"filename": "hero-upholstery.jpg", "dir": HERO_DIR, "prompt": ( - "50mm lens low corner angle across a bright residential living room, " - "plush linen fabric sofa arm sharp in near foreground, clean armchair and window receding with bokeh, " - "afternoon countryside light through window, shallow depth of field, " - "no people, ultra-realistic interior photography, 16:9" - )}, - {"filename": "hero-floors.jpg", "dir": HERO_DIR, "prompt": ( - "low 24mm angle pressed to gleaming light oak hardwood floor, " - "floor grain razor sharp in extreme foreground receding to hallway vanishing point, " - "white walls, natural light streaming in, shallow depth of field, " - "no people, ultra-realistic interior photography, 16:9" - )}, - {"filename": "hero-area-rugs.jpg", "dir": HERO_DIR, "prompt": ( - "low 35mm angle looking across a hand-knotted oriental rug from floor level, " - "rich red and gold rug fibers sharp in foreground, hardwood floor and room receding into bokeh, " - "cozy farmhouse living room, warm natural light, shallow depth of field, " - "no people, ultra-realistic interior photography, 16:9" - )}, - {"filename": "hero-add-ons.jpg", "dir": HERO_DIR, "prompt": ( - "low 35mm angle across a clean beige bedroom carpet, " - "carpet pile sharp and detailed in near foreground, wooden bed frame and sheer curtained window receding, " - "crisp morning light, shallow depth of field, " - "no people, no machines, ultra-realistic interior photography, 16:9" - )}, - {"filename": "hero-commercial.jpg", "dir": HERO_DIR, "prompt": ( - "low 24mm wide-angle lens across a modern corporate lobby floor, " - "dark charcoal commercial carpet sharp in extreme foreground receding to glass entrance doors, " - "recessed ceiling lights creating depth, strong vanishing point perspective, " - "no people, ultra-realistic architectural photography, 16:9" - )}, - {"filename": "hero-offices.jpg", "dir": HERO_DIR, "prompt": ( - "low 24mm angle across clean grey carpet tiles in a modern open-plan office, " - "carpet tile seams sharp in foreground receding to rows of empty desks and glass partitions, " - "professional overhead lighting, strong linear perspective, " - "no people, ultra-realistic architectural photography, 16:9" - )}, - {"filename": "hero-vacation-rentals.jpg", "dir": HERO_DIR, "prompt": ( - "low 35mm angle across clean beige carpet in a Finger Lakes cottage living room, " - "carpet fibers sharp in foreground, stone fireplace and lake-view window receding with bokeh, " - "wooden ceiling beams, warm inviting light, shallow depth of field, " - "no people, ultra-realistic interior photography, 16:9" - )}, - {"filename": "hero-hotels.jpg", "dir": HERO_DIR, "prompt": ( - "low 24mm lens looking down a long hotel corridor from floor level, " - "patterned burgundy carpet runner sharp in extreme foreground receding to vanishing point, " - "warm wall sconces lining white walls, numbered doors converging in perspective, " - "no people, ultra-realistic hospitality photography, 16:9" - )}, - {"filename": "hero-retail.jpg", "dir": HERO_DIR, "prompt": ( - "low 35mm diagonal angle across clean light grey carpet in an upscale retail showroom, " - "carpet surface sharp in foreground, minimalist display fixtures and storefront windows receding with bokeh, " - "bright track lighting overhead, shallow depth of field, " - "no people, ultra-realistic architectural photography, 16:9" - )}, - {"filename": "hero-property-management.jpg", "dir": HERO_DIR, "prompt": ( - "low 35mm angle across fresh neutral carpet in an empty move-in ready apartment, " - "carpet texture sharp in foreground, bare white walls and bright windows receding, " - "clean real estate photography perspective, shallow depth of field, " - "no people, ultra-realistic real estate photography, 16:9" - )}, - {"filename": "hero-about.jpg", "dir": HERO_DIR, "prompt": ( - "low 35mm angle from lawn level looking up at a classic upstate New York suburban home, " - "green grass blades sharp in extreme foreground, inviting house facade receding upward, " - "mature trees and clear blue sky, warm summer afternoon, " - "no people, ultra-realistic real estate photography, 16:9" - )}, - {"filename": "hero-service-area.jpg", "dir": HERO_DIR, "prompt": ( - "low horizon 24mm wide-angle Finger Lakes landscape, " - "green vineyard vines sharp in foreground receding to rolling hills and calm lake, " - "golden hour light casting long shadows, strong depth and distance, " - "no people, ultra-realistic landscape photography, 16:9" - )}, - {"filename": "hero-living-room.jpg", "dir": HERO_DIR, "prompt": ( - "low 35mm corner angle across a spacious residential living room, " - "plush light grey carpet sharp and textured in foreground, large sectional sofa and bay windows receding with bokeh, " - "warm afternoon sunlight, shallow depth of field, " - "no people, ultra-realistic interior photography, 16:9" - )}, - {"filename": "hero-clean-result.jpg", "dir": HERO_DIR, "prompt": ( - "extreme low 50mm macro angle pressed to immaculate freshly cleaned residential carpet, " - "individual carpet fibers razor sharp in foreground, pile receding into soft bokeh, " - "raking natural light revealing deep clean texture and uniform pile height, " - "no people, ultra-realistic macro carpet photography, 16:9" - )}, - # --- SERVICE CARD IMAGES --- - {"filename": "carpet-cleaning.jpg", "dir": SVC_DIR, "prompt": ( - "low 35mm angle looking across plush clean beige carpet in a residential living room, " - "carpet fibers sharp in foreground, couch and window receding into bokeh, " - "warm afternoon light, shallow depth of field, no people, ultra-realistic interior photography" - )}, - {"filename": "stairs-cleaning.jpg", "dir": SVC_DIR, "prompt": ( - "low 35mm angle looking up clean grey carpeted stairs from bottom step, " - "carpet texture sharp on nearest step, stairs receding diagonally upward, " - "wood banister, bright light from above, no people, ultra-realistic interior photography" - )}, - {"filename": "upholstery-cleaning.jpg", "dir": SVC_DIR, "prompt": ( - "low 50mm angle across a clean plush linen fabric sofa arm, " - "fabric weave sharp in foreground, living room receding with bokeh, " - "warm light, shallow depth of field, no people, ultra-realistic interior photography" - )}, - {"filename": "floor-cleaning.jpg", "dir": SVC_DIR, "prompt": ( - "low 24mm angle pressed to gleaming light oak hardwood floor, " - "wood grain razor sharp in extreme foreground receding down hallway, " - "natural light, no people, ultra-realistic interior photography" - )}, - {"filename": "area-rug-cleaning.jpg", "dir": SVC_DIR, "prompt": ( - "low 35mm angle across a vibrant clean oriental rug from floor level, " - "rug fibers and pattern sharp in foreground, hardwood floor and room receding, " - "warm light, shallow depth of field, no people, ultra-realistic interior photography" - )}, - {"filename": "add-ons.jpg", "dir": SVC_DIR, "prompt": ( - "low 35mm angle across clean beige bedroom carpet, " - "carpet pile sharp in foreground, bed frame and curtained window receding with bokeh, " - "morning light, no people, ultra-realistic interior photography" - )}, - {"filename": "commercial-overview.jpg", "dir": SVC_DIR, "prompt": ( - "low 24mm angle across dark commercial carpet in a corporate lobby, " - "carpet surface sharp in foreground receding to glass entrance, " - "strong vanishing point, no people, ultra-realistic architectural photography" - )}, - {"filename": "vacation-rentals.jpg", "dir": SVC_DIR, "prompt": ( - "low 35mm angle across clean carpet in a Finger Lakes cottage living room, " - "carpet sharp in foreground, stone fireplace and window receding with bokeh, " - "rustic warm decor, no people, ultra-realistic interior photography" - )}, - {"filename": "office-spaces.jpg", "dir": SVC_DIR, "prompt": ( - "low 24mm angle across grey carpet tiles in a modern open office, " - "tile seams sharp in foreground, empty desks receding with linear perspective, " - "professional lighting, no people, ultra-realistic architectural photography" - )}, - {"filename": "hotels-inns.jpg", "dir": SVC_DIR, "prompt": ( - "low 24mm angle down a hotel corridor, patterned carpet runner sharp in foreground, " - "corridor receding to vanishing point, warm wall sconces, " - "no people, ultra-realistic hospitality photography" - )}, - {"filename": "retail-showrooms.jpg", "dir": SVC_DIR, "prompt": ( - "low 35mm diagonal angle across light grey carpet in an upscale retail showroom, " - "carpet sharp in foreground, display fixtures and track lighting receding with bokeh, " - "no people, ultra-realistic architectural photography" - )}, - {"filename": "property-management.jpg", "dir": SVC_DIR, "prompt": ( - "low 35mm angle across fresh neutral carpet in an empty clean apartment, " - "carpet texture sharp in foreground, white walls and windows receding, " - "no people, ultra-realistic real estate photography" - )}, -] - - -def build_workflow(prompt, seed=None): - if seed is None: - seed = random.randint(0, 2**32) - return { - "1": { - "class_type": "UnetLoaderGGUF", - "inputs": {"unet_name": "flux1-schnell-Q8_0.gguf"}, - }, - "2": { - "class_type": "DualCLIPLoader", - "inputs": { - "clip_name1": "t5xxl_fp8_e4m3fn.safetensors", - "clip_name2": "clip_l.safetensors", - "type": "flux", - }, - }, - "3": { - "class_type": "VAELoader", - "inputs": {"vae_name": "ae.safetensors"}, - }, - "4": { - "class_type": "CLIPTextEncode", - "inputs": {"clip": ["2", 0], "text": prompt}, - }, - "5": { - "class_type": "EmptyLatentImage", - "inputs": {"batch_size": 1, "height": 576, "width": 1024}, - }, - "6": { - "class_type": "KSampler", - "inputs": { - "cfg": 1.0, - "denoise": 1.0, - "latent_image": ["5", 0], - "model": ["1", 0], - "negative": ["4", 0], - "positive": ["4", 0], - "sampler_name": "euler", - "scheduler": "simple", - "seed": seed, - "steps": 4, - }, - }, - "7": { - "class_type": "VAEDecode", - "inputs": {"samples": ["6", 0], "vae": ["3", 0]}, - }, - "8": { - "class_type": "SaveImage", - "inputs": {"filename_prefix": "flux_lahr", "images": ["7", 0]}, - }, - } - - -def queue_prompt(workflow): - data = json.dumps({"prompt": workflow}).encode() - req = urllib.request.Request( - f"{COMFY}/prompt", data=data, - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read())["prompt_id"] - - -def wait_for_result(prompt_id, timeout=600): - start = time.time() - while time.time() - start < timeout: - try: - with urllib.request.urlopen(f"{COMFY}/history/{prompt_id}") as resp: - hist = json.loads(resp.read()) - if prompt_id in hist: - entry = hist[prompt_id] - status = entry.get("status", {}).get("status_str", "") - if status == "error": - msgs = entry.get("status", {}).get("messages", []) - print(f" COMFYUI ERROR: {msgs}", flush=True) - return None - for node_out in entry.get("outputs", {}).values(): - if "images" in node_out: - return node_out["images"] - except Exception: - pass - time.sleep(5) - return None - - -def download_image(img_info, out_path): - fname = img_info["filename"] - subfolder = img_info.get("subfolder", "") - img_type = img_info.get("type", "output") - url = f"{COMFY}/view?filename={fname}&subfolder={subfolder}&type={img_type}" - with urllib.request.urlopen(url) as resp: - data = resp.read() - try: - from PIL import Image - img = Image.open(io.BytesIO(data)).convert("RGB") - img.save(out_path, "JPEG", quality=92) - print(f" OK: {os.path.basename(out_path)} ({os.path.getsize(out_path)//1024}KB)", flush=True) - except ImportError: - png_path = out_path.replace(".jpg", ".png") - with open(png_path, "wb") as f: - f.write(data) - print(f" OK (PNG): {png_path}", flush=True) - - -total = len(IMAGES) -for i, spec in enumerate(IMAGES): - out_path = os.path.join(spec["dir"], spec["filename"]) - print(f"\n[{i+1}/{total}] {spec['filename']}", flush=True) - workflow = build_workflow(spec["prompt"]) - prompt_id = queue_prompt(workflow) - print(f" queued {prompt_id[:8]}...", flush=True) - images = wait_for_result(prompt_id) - if images: - download_image(images[0], out_path) - else: - print(f" FAILED (timeout)", flush=True) - -print("\nAll done.", flush=True) diff --git a/tools/gen-locations.py b/tools/gen-locations.py deleted file mode 100644 index 566618f..0000000 --- a/tools/gen-locations.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Lahr Carpet Cleaning — Location page generator. -Creates /locations//index.html for each city. -Run: python3 tools/gen-locations.py -""" -import os - -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -LOC_DIR = os.path.join(BASE_DIR, "locations") - -CITIES = [ - {"name": "Waterloo", "slug": "waterloo-ny", "county": "Seneca County", "note": "Our home base. Fastest response times in the area."}, - {"name": "Geneva", "slug": "geneva-ny", "county": "Ontario County", "note": "Full residential and commercial services throughout Geneva."}, - {"name": "Seneca Falls", "slug": "seneca-falls-ny", "county": "Seneca County", "note": "Serving homes, vacation rentals, and businesses in Seneca Falls."}, - {"name": "Canandaigua", "slug": "canandaigua-ny", "county": "Ontario County", "note": "Lakefront homes, rentals, and businesses along Canandaigua Lake."}, - {"name": "Penn Yan", "slug": "penn-yan-ny", "county": "Yates County", "note": "Homes, wineries, and short-term rentals in the Penn Yan area."}, - {"name": "Newark", "slug": "newark-ny", "county": "Wayne County", "note": "Carpet and upholstery cleaning for homes and businesses in Newark."}, - {"name": "Clifton Springs", "slug": "clifton-springs-ny", "county": "Ontario County", "note": "Residential and commercial cleaning throughout Clifton Springs."}, - {"name": "Lodi", "slug": "lodi-ny", "county": "Seneca County", "note": "Serving homes and vacation properties in Lodi and surrounding areas."}, - {"name": "Himrod", "slug": "himrod-ny", "county": "Yates County", "note": "Carpet and floor cleaning for homes and rentals in the Himrod area."}, - {"name": "Phelps", "slug": "phelps-ny", "county": "Ontario County", "note": "Residential carpet and upholstery cleaning throughout Phelps."}, - {"name": "Shortsville", "slug": "shortsville-ny", "county": "Ontario County", "note": "Home and business cleaning services in Shortsville, NY."}, - {"name": "Victor", "slug": "victor-ny", "county": "Ontario County", "note": "Residential and commercial carpet cleaning throughout Victor."}, - {"name": "Naples", "slug": "naples-ny", "county": "Ontario County", "note": "Serving homes, wineries, and vacation rentals in the Naples area."}, - {"name": "Gorham", "slug": "gorham-ny", "county": "Ontario County", "note": "Carpet and floor cleaning for homes and properties in Gorham."}, - {"name": "Manchester", "slug": "manchester-ny", "county": "Ontario County", "note": "Residential and commercial cleaning services in Manchester, NY."}, - {"name": "Ovid", "slug": "ovid-ny", "county": "Seneca County", "note": "Serving homes and rental properties throughout Ovid."}, - {"name": "Clyde", "slug": "clyde-ny", "county": "Wayne County", "note": "Carpet, upholstery, and floor cleaning for homes and businesses in Clyde."}, - {"name": "Farmington", "slug": "farmington-ny", "county": "Ontario County", "note": "Residential and commercial carpet cleaning throughout Farmington."}, - {"name": "East Bloomfield", "slug": "east-bloomfield-ny", "county": "Ontario County", "note": "Serving homes and properties in East Bloomfield and surrounding areas."}, - {"name": "Rushville", "slug": "rushville-ny", "county": "Yates County", "note": "Carpet and upholstery cleaning for homes and rentals in Rushville."}, - {"name": "Finger Lakes", "slug": "finger-lakes-ny", "county": "Region", "note": "Serving vacation rentals, wineries, and homes across the Finger Lakes region."}, -] - -SERVICES = [ - {"name": "Carpet Cleaning", "slug": "/services/carpet-cleaning/", "img": "/assets/images/services/carpet-cleaning.jpg", "sub": "In-Home Service", "desc": "Hot water extraction removes deep-seated dirt, allergens, and stains from carpet fibers throughout your home."}, - {"name": "Stairs Cleaning", "slug": "/services/stairs/", "img": "/assets/images/services/stairs-cleaning.jpg", "sub": "Step by Step", "desc": "Stairs collect more dirt per square inch than any flat surface. We clean every tread, riser, and landing."}, - {"name": "Upholstery Cleaning","slug": "/services/upholstery/", "img": "/assets/images/services/upholstery-cleaning.jpg","sub": "Furniture Refresh", "desc": "Safe, effective cleaning for sofas, chairs, and mattresses. We work with all fabric types and leave no residue."}, - {"name": "Floor Cleaning", "slug": "/services/floors/", "img": "/assets/images/services/floor-cleaning.jpg", "sub": "Hard Surface Care", "desc": "Wood floor cleaning and tile and grout restoration that brings hard surfaces back to their original condition."}, - {"name": "Area Rug Cleaning", "slug": "/services/area-rugs/", "img": "/assets/images/services/area-rug-cleaning.jpg", "sub": "Delicate Care", "desc": "Gentle, specialized cleaning for oriental, Persian, and delicate rugs that restores color and removes embedded dirt."}, - {"name": "Add-On Services", "slug": "/services/add-ons/", "img": "/assets/images/services/add-ons.jpg", "sub": "Extra Care", "desc": "Furniture moving, pet hair removal, odor treatment, and heavily soiled area care available alongside any service."}, -] - -HERO_IMAGES = [ - "/assets/images/hero/hero-living-room.jpg", - "/assets/images/hero/hero-clean-result.jpg", - "/assets/images/hero/hero-technician.jpg", - "/assets/images/hero/hero-before-after.jpg", - "/assets/images/hero/hero-stairs.jpg", -] - - -def service_card(svc, city_name): - accent_word, rest = svc["name"].split(" ", 1) if " " in svc["name"] else (svc["name"], "") - h3 = f'{accent_word} {rest}'.strip() - return f"""
-
{svc['name']} in {city_name}, NY
-

{h3}

-
{svc['sub']}
-

{svc['desc']}

- Learn More -
""" - - -def page_html(city, idx): - hero_img = HERO_IMAGES[idx % len(HERO_IMAGES)] - cards = "\n".join(service_card(s, city["name"]) for s in SERVICES) - name = city["name"] - county = city["county"] - note = city["note"] - slug = city["slug"] - - return f""" - - - - - Carpet Cleaning in {name}, NY | Lahr Carpet Cleaning - - - - - - - -
-
-
- {county} — Finger Lakes -

{name},
NY

-

{note}

- -
-
-
- -
-
-
- -

Services in {name}

-

We serve {name} and the surrounding {county} communities. Call to confirm availability for your address.

-
-
-{cards} -
-
-
- -
-
-

Serving {name}

-

Call 315-719-1218 or submit the form for a free estimate in {name}, NY.

- Get a Free Estimate -
-
- - - - - - -""" - - -def locations_index(): - city_cards = [] - for i, city in enumerate(CITIES): - img = HERO_IMAGES[i % len(HERO_IMAGES)] - name = city["name"] - county = city["county"] - note = city["note"] - slug = city["slug"] - if " " in name: - accent_word, rest = name.split(" ", 1) - h3 = f'{accent_word} {rest}, NY' - else: - h3 = f'{name}, NY' - city_cards.append(f"""
-
{name} NY
-

{h3}

-
{county}
-

{note}

- View Services -
""") - - cards_html = "\n".join(city_cards) - return f""" - - - - - Service Areas | Lahr Carpet Cleaning | Finger Lakes, NY - - - - - - - -
-
-
- Finger Lakes Region -

Service
Areas

-

We clean carpets, upholstery, rugs, and hard floors across 21 cities in Upstate New York. Select your city below.

- -
-
-
- -
-
-
- -

Cities We Serve

-

Based in Waterloo, NY. We travel throughout Seneca, Ontario, Yates, Wayne, and Cayuga counties.

-
-
-{cards_html} -
-
-
- -
-
-

Not sure if we cover your area?

-

Call 315-719-1218 or submit the form and we will confirm availability for your address.

- Get a Free Estimate -
-
- - - - - - -""" - - -if __name__ == "__main__": - # Write locations index - with open(os.path.join(LOC_DIR, "index.html"), "w") as f: - f.write(locations_index()) - print("Wrote locations/index.html") - - # Write each city page - for i, city in enumerate(CITIES): - city_dir = os.path.join(LOC_DIR, city["slug"]) - os.makedirs(city_dir, exist_ok=True) - out_path = os.path.join(city_dir, "index.html") - with open(out_path, "w") as f: - f.write(page_html(city, i)) - print(f"Wrote locations/{city['slug']}/index.html") - - print(f"\nDone. {len(CITIES)} city pages + index generated.") diff --git a/tools/gen-service-images.py b/tools/gen-service-images.py deleted file mode 100644 index 7e60816..0000000 --- a/tools/gen-service-images.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -Lahr Carpet Cleaning — Service card image generator. -Generates 12 unique images for residential and commercial service cards. -Saves to: assets/images/services/ -Run: python3 tools/gen-service-images.py -""" -import os -import sys - -try: - from google import genai - from google.genai import types -except ImportError: - print("Installing google-genai...") - os.system(f"{sys.executable} -m pip install google-genai --quiet") - from google import genai - from google.genai import types - -API_KEY = os.environ.get("GEMINI_API_KEY", "AIzaSyB_1p8KvaT_rdNJGPs8HKk8bKsvUlcL6Kg") -OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "images", "services") -os.makedirs(OUT_DIR, exist_ok=True) - -client = genai.Client(api_key=API_KEY) - -IMAGES = [ - # ── Residential ────────────────────────────────────────────────────────── - { - "name": "carpet-cleaning", - "prompt": ( - "Wide shot of a large industrial stand-up hot water extraction machine being pushed across " - "a plush beige residential carpet. The machine is a heavy commercial-grade upright extractor " - "on wheels — tall, wide cleaning head at the base, long upright handle. " - "The carpet behind it transitions from dirty and matted to clean, bright, and fluffy. " - "Completely dry machine exterior, no steam, no water spraying anywhere. " - "Warm natural interior light. Ultra-realistic professional photography." - ), - }, - { - "name": "stairs-cleaning", - "prompt": ( - "Wide cinematic shot of a carpeted residential staircase. Each step has clean, " - "bright plush carpet that looks freshly cleaned with visible extraction lines. " - "Modern upstate New York home interior, natural light from above, " - "warm wood banisters, no people, no equipment visible. " - "Professional interior photography, ultra-realistic." - ), - }, - { - "name": "upholstery-cleaning", - "prompt": ( - "Close-up of a clean grey linen sofa cushion showing bright, lifted fabric texture " - "after professional upholstery cleaning. Half the cushion shows the before " - "(slightly soiled, flat fabric) and half shows the cleaned result (bright, fluffy, refreshed). " - "Natural window light, residential living room in background, no people, no equipment. " - "Ultra-realistic product photography." - ), - }, - { - "name": "floor-cleaning", - "prompt": ( - "Wide shot of a gleaming hardwood floor in a modern residential home after professional " - "cleaning. The floor reflects soft natural window light, showing deep grain detail. " - "Contemporary furniture in background, no people, no cleaning equipment visible. " - "Professional interior photography, ultra-realistic." - ), - }, - { - "name": "area-rug-cleaning", - "prompt": ( - "Overhead flat-lay shot of a large vibrant Persian or oriental area rug " - "with rich red, navy, and cream geometric patterns, looking freshly cleaned — " - "colors vivid, fibers lifted and bright. Hardwood floor beneath. " - "No people, no equipment, no water. Professional product photography, ultra-realistic." - ), - }, - { - "name": "add-ons", - "prompt": ( - "Close-up macro shot of clean carpet fibers being lifted by a professional " - "grooming brush after hot water extraction cleaning. The fibers are bright, " - "fluffy, and standing upright. Warm light catches the texture. " - "No steam, no water, no people. Ultra-realistic macro photography." - ), - }, - # ── Commercial ─────────────────────────────────────────────────────────── - { - "name": "vacation-rentals", - "prompt": ( - "Bright, airy vacation rental living room in the Finger Lakes region of upstate New York. " - "Spotlessly clean cream carpet, contemporary furniture, large windows with lake views, " - "warm natural afternoon light. Inviting and fresh. No people, no equipment. " - "Professional real estate photography, ultra-realistic." - ), - }, - { - "name": "office-spaces", - "prompt": ( - "Wide shot of a clean modern corporate office with freshly cleaned dark charcoal carpet " - "throughout. Open plan workspace, glass partitions, professional lighting. " - "Carpet shows neat vacuum lines indicating recent professional cleaning. " - "No people, no equipment. Professional architectural photography, ultra-realistic." - ), - }, - { - "name": "hotels-inns", - "prompt": ( - "Elegant hotel corridor in a boutique upstate New York inn. Clean, plush patterned " - "carpet runner down the hallway with fresh vacuum lines. Warm sconce lighting, " - "wood paneling, framed art on walls. No people, no equipment. " - "Professional hospitality photography, ultra-realistic." - ), - }, - { - "name": "retail-showrooms", - "prompt": ( - "Wide shot of an upscale retail showroom or winery tasting room in the Finger Lakes. " - "Clean, rich carpet throughout, warm lighting, product displays on shelves. " - "Carpet looks freshly extracted — bright and spotless. " - "No people, no equipment. Professional commercial interior photography, ultra-realistic." - ), - }, - { - "name": "property-management", - "prompt": ( - "View across three clean apartment living rooms in sequence, each showing " - "spotlessly clean beige carpet with fresh vacuum lines after professional cleaning. " - "Bright, neutral interiors ready for new tenants. Natural light, no furniture, " - "no people, no equipment. Professional real estate photography, ultra-realistic." - ), - }, - { - "name": "commercial-overview", - "prompt": ( - "Professional carpet cleaning technician in a plain black shirt, shown from the side, " - "pushing a large industrial stand-up hot water extraction machine through a bright commercial " - "building lobby. The machine is a heavy commercial-grade upright extractor on wheels — " - "tall, wide cleaning head, long handle. Clean carpet visible. No steam, no water spraying, " - "no face visible. Professional editorial photography, ultra-realistic." - ), - }, -] - - -def generate(): - saved = [] - total = len(IMAGES) - for i, item in enumerate(IMAGES, 1): - out_path = os.path.join(OUT_DIR, f"{item['name']}.jpg") - print(f"[{i}/{total}] Generating {item['name']}...") - try: - resp = client.models.generate_images( - model="imagen-4.0-generate-001", - prompt=item["prompt"], - config=types.GenerateImagesConfig( - number_of_images=1, - aspect_ratio="4:3", - output_mime_type="image/jpeg", - safety_filter_level="block_low_and_above", - ), - ) - if resp.generated_images: - img_bytes = resp.generated_images[0].image.image_bytes - with open(out_path, "wb") as f: - f.write(img_bytes) - print(f" Saved {out_path} ({len(img_bytes)//1024}KB)") - saved.append(item["name"]) - else: - print(f" No image returned for {item['name']}") - except Exception as e: - print(f" Error on {item['name']}: {e}") - - return saved - - -if __name__ == "__main__": - saved = generate() - print(f"\nDone. {len(saved)}/{len(IMAGES)} images saved to assets/images/services/") - if saved: - print("Generated:", ", ".join(saved)) diff --git a/tools/gen-video-wan.py b/tools/gen-video-wan.py deleted file mode 100644 index 5c46300..0000000 --- a/tools/gen-video-wan.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Generate carpet cleaning reel clips via Wan 2.2 TI2V (text+image to video) through ComfyUI. -Uses FLUX-generated hero stills as input frames, animates each into a 3-5 second clip. -Run after gen-images-flux.py completes and images are converted to webp. - -Usage: - python3 tools/gen-video-wan.py - -Output: assets/videos/clips/*.mp4 — stitch with ffmpeg into final reel. -""" -import json, time, urllib.request, os, random, io - -COMFY = "http://localhost:8188" -HERO_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "images", "hero") -OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "videos", "clips") -os.makedirs(OUT_DIR, exist_ok=True) - -WAN_MODEL = "Wan2.2-TI2V-5B-Q4_K_M.gguf" - -# Each clip: input still + motion prompt → 3-5 sec animated clip -# Order matches the reel sequence -CLIPS = [ - { - "filename": "clip-01-carpet.mp4", - "image": "hero-carpet-cleaning.webp", - "prompt": "slow dolly forward across clean plush carpet, gentle camera push toward the far wall, warm afternoon light, cinematic, smooth motion", - "frames": 49, # ~4 seconds at ~12fps - }, - { - "filename": "clip-02-stairs.mp4", - "image": "hero-stairs.webp", - "prompt": "slow pan upward along clean carpeted staircase, camera tilts up following the banister, soft natural light, cinematic motion", - "frames": 49, - }, - { - "filename": "clip-03-upholstery.mp4", - "image": "hero-upholstery.webp", - "prompt": "gentle push in toward clean linen sofa, shallow depth of field, warm light, slow cinematic camera movement", - "frames": 49, - }, - { - "filename": "clip-04-commercial.mp4", - "image": "hero-commercial.webp", - "prompt": "slow tracking shot moving forward down a clean corporate lobby, receding vanishing point, professional lighting, cinematic", - "frames": 49, - }, - { - "filename": "clip-05-floors.mp4", - "image": "hero-floors.webp", - "prompt": "floor-level drift forward along gleaming hardwood, camera slides smoothly down the hallway, natural light", - "frames": 49, - }, - { - "filename": "clip-06-clean-result.mp4", - "image": "hero-clean-result.webp", - "prompt": "slow rack focus across clean carpet fibers, foreground to background, raking natural light, macro detail, cinematic", - "frames": 49, - }, -] - - -def load_image_as_base64(image_path): - import base64 - with open(image_path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") - - -def upload_image(image_path): - """Upload image to ComfyUI and return the filename it assigned.""" - import base64 - fname = os.path.basename(image_path) - with open(image_path, "rb") as f: - img_data = f.read() - boundary = "----FormBoundary" + str(random.randint(100000, 999999)) - body = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="image"; filename="{fname}"\r\n' - f"Content-Type: image/webp\r\n\r\n" - ).encode() + img_data + f"\r\n--{boundary}--\r\n".encode() - req = urllib.request.Request( - f"{COMFY}/upload/image", - data=body, - headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, - ) - with urllib.request.urlopen(req) as resp: - result = json.loads(resp.read()) - return result["name"] - - -def build_workflow(image_name, prompt, frames, seed=None): - if seed is None: - seed = random.randint(0, 2**32) - return { - "1": { - "class_type": "UnetLoaderGGUF", - "inputs": {"unet_name": WAN_MODEL}, - }, - "2": { - "class_type": "CLIPLoader", - "inputs": { - "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", - "type": "wan", - }, - }, - "3": { - "class_type": "VAELoader", - "inputs": {"vae_name": "wan_2.1_vae.safetensors"}, - }, - "4": { - "class_type": "LoadImage", - "inputs": {"image": image_name}, - }, - "5": { - "class_type": "CLIPTextEncode", - "inputs": {"clip": ["2", 0], "text": prompt}, - }, - "6": { - "class_type": "CLIPTextEncode", - "inputs": {"clip": ["2", 0], "text": "blur, low quality, distortion, text, watermark, people, faces"}, - }, - "7": { - "class_type": "WanImageToVideo", - "inputs": { - "model": ["1", 0], - "clip": ["2", 0], - "vae": ["3", 0], - "image": ["4", 0], - "positive": ["5", 0], - "negative": ["6", 0], - "width": 832, - "height": 480, - "length": frames, - "batch_size": 1, - "seed": seed, - "steps": 20, - "cfg": 6.0, - "sampler_name": "uni_pc", - "scheduler": "simple", - "denoise": 1.0, - }, - }, - "8": { - "class_type": "SaveAnimatedWEBP", - "inputs": { - "images": ["7", 0], - "filename_prefix": "wan_lahr", - "fps": 16, - "lossless": False, - "quality": 85, - "method": "default", - }, - }, - } - - -def queue_prompt(workflow): - data = json.dumps({"prompt": workflow}).encode() - req = urllib.request.Request( - f"{COMFY}/prompt", data=data, - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read())["prompt_id"] - - -def wait_for_result(prompt_id, timeout=1800): - start = time.time() - while time.time() - start < timeout: - try: - with urllib.request.urlopen(f"{COMFY}/history/{prompt_id}") as resp: - hist = json.loads(resp.read()) - if prompt_id in hist: - entry = hist[prompt_id] - if entry.get("status", {}).get("status_str") == "error": - print(f" ERROR: {entry['status'].get('messages', '')}", flush=True) - return None - for node_out in entry.get("outputs", {}).values(): - if "images" in node_out: - return node_out["images"] - if "gifs" in node_out: - return node_out["gifs"] - except Exception: - pass - time.sleep(8) - print(" waiting...", flush=True) - return None - - -def download_video(vid_info, out_path): - fname = vid_info["filename"] - subfolder = vid_info.get("subfolder", "") - img_type = vid_info.get("type", "output") - url = f"{COMFY}/view?filename={fname}&subfolder={subfolder}&type={img_type}" - with urllib.request.urlopen(url) as resp: - data = resp.read() - with open(out_path, "wb") as f: - f.write(data) - print(f" saved: {os.path.basename(out_path)} ({len(data)//1024}KB)", flush=True) - - -total = len(CLIPS) -for i, clip in enumerate(CLIPS): - print(f"\n[{i+1}/{total}] {clip['filename']}", flush=True) - image_path = os.path.join(HERO_DIR, clip["image"]) - if not os.path.exists(image_path): - print(f" SKIP: {image_path} not found", flush=True) - continue - print(f" uploading {clip['image']}...", flush=True) - image_name = upload_image(image_path) - workflow = build_workflow(image_name, clip["prompt"], clip["frames"]) - prompt_id = queue_prompt(workflow) - print(f" queued {prompt_id[:8]}...", flush=True) - results = wait_for_result(prompt_id) - if results: - out_path = os.path.join(OUT_DIR, clip["filename"]) - # rename .webp output to .mp4 for compatibility — or save as webp animation - out_path_webp = out_path.replace(".mp4", ".webp") - download_video(results[0], out_path_webp) - else: - print(f" FAILED", flush=True) - -print("\nAll clips done. Stitch with:") -print(f" ffmpeg -f concat -safe 0 -i tools/clip-list.txt -c copy assets/videos/hero-reel-flux.mp4") diff --git a/tools/gen-video.py b/tools/gen-video.py deleted file mode 100644 index e4f864e..0000000 --- a/tools/gen-video.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -Lahr Carpet Cleaning — Veo hero video generator. -5 shots x 4s = 20s reel. Concatenated by ffmpeg into hero-reel.mp4. -Saves clips to: assets/videos/hero/clips/ -Saves final to: assets/videos/hero/hero-reel.mp4 -Run: python3 tools/gen-video.py -""" -import os -import sys -import time -import subprocess - -try: - from google import genai - from google.genai import types -except ImportError: - print("Installing google-genai...") - os.system(f"{sys.executable} -m pip install google-genai --quiet") - from google import genai - from google.genai import types - -API_KEY = os.environ.get("GEMINI_API_KEY", "AIzaSyB_1p8KvaT_rdNJGPs8HKk8bKsvUlcL6Kg") -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -OUT_DIR = os.path.join(BASE_DIR, "assets", "videos", "hero", "clips") -REEL_OUT = os.path.join(BASE_DIR, "assets", "videos", "hero", "hero-reel.mp4") -os.makedirs(OUT_DIR, exist_ok=True) - -client = genai.Client(api_key=API_KEY) - -SHOTS = [ - { - "name": "shot-01-door-opens", - "prompt": ( - "Cinematic low-angle wide shot. A solid wood front door of an upstate New York home opens " - "inward smoothly. Bright golden afternoon sunlight pours through the doorway onto a carpeted " - "entryway floor. Camera is at floor level, looking toward the door. The door swings open " - "fully revealing light. No people visible. Photorealistic, warm inviting light, slow motion." - ), - }, - { - "name": "shot-02-pan-to-stains", - "prompt": ( - "Slow cinematic camera pan from the front door entryway across a residential living room carpet " - "in an upstate New York home. The carpet shows visible dirt tracks, pet stains, and soiling " - "from daily use. Natural light. No people. Camera moves fluidly across the room revealing " - "the stained carpet. Photorealistic." - ), - }, - { - "name": "shot-03-stain-closeup", - "prompt": ( - "Close-up shot of a stained beige carpet with visible pet stains, mud, and dark soiling. " - "Camera slowly pushes in on the dirty area. Dramatic side lighting emphasises the stain depth " - "and texture. Slow motion. Ultra-realistic macro photography style." - ), - }, - { - "name": "shot-04-extraction-carpet", - "prompt": ( - "Cinematic slow-motion wide shot: a large industrial stand-up hot water extraction machine " - "being pushed steadily forward across a beige residential carpet. The machine is a tall " - "professional-grade upright extractor — heavy-duty, commercial size, on wheels, with a wide " - "cleaning head at the base and an upright handle. No steam, no spraying water, no visible " - "liquid anywhere on the machine exterior. The carpet behind the machine transitions from dirty " - "and matted to bright, clean, and fluffy as it passes. Warm natural room light. Photorealistic." - ), - }, - { - "name": "shot-05-extraction-couch", - "prompt": ( - "Close-up cinematic shot of a professional technician's gloved hand holding a small flat " - "upholstery cleaning attachment tool, pressing it firmly against a dirty grey sofa cushion " - "and sliding it slowly across the fabric. The fabric visibly brightens and lifts as the tool " - "moves. No water pours out — suction draws moisture into the tool. Slow motion, natural light. " - "Photorealistic." - ), - }, - { - "name": "shot-06-extraction-stairs", - "prompt": ( - "Cinematic shot of a professional technician's hands using a compact portable upright carpet " - "cleaner on a carpeted staircase — pushing the machine up a stair tread step by step. Each " - "tread brightens and looks freshly cleaned as the machine passes. No water pours out. Clean " - "bright carpet revealed on each step. Slow motion, warm interior light. Photorealistic." - ), - }, - { - "name": "shot-07-office-entryway", - "prompt": ( - "Wide cinematic shot of a clean professional office building entryway with commercial grade " - "carpet. Modern corporate interior, glass doors, professional lighting. No people. Camera " - "slowly pushes forward through the entry. Photorealistic." - ), - }, - { - "name": "shot-08-showroom", - "prompt": ( - "Wide cinematic shot of an upscale retail showroom or winery tasting room in the Finger Lakes " - "region. Rich carpet throughout, warm interior lighting, product displays. No people. Camera " - "glides forward through the space. Photorealistic, luxurious atmosphere." - ), - }, - { - "name": "shot-09-technician-unloading", - "prompt": ( - "Wide shot of a professional carpet cleaning technician wearing a plain black shirt with no logo, " - "rolling a large industrial stand-up hot water extraction machine out of a white service van " - "parked in a residential driveway in upstate New York. The machine is a heavy commercial-grade " - "upright extractor on wheels — tall, industrial size. Autumn trees in background, bright day. " - "Technician shown from side or behind, no face visible. Photorealistic." - ), - }, -] - -MODELS = [ - "veo-2.0-generate-001", - "veo-3.0-generate-001", -] - -def poll(operation, timeout=420): - elapsed = 0 - while not operation.done: - if elapsed >= timeout: - print(" Timed out.") - return None - print(f" Waiting... ({elapsed}s)") - time.sleep(15) - elapsed += 15 - operation = client.operations.get(operation) - return operation - -def download_video(video, out_path): - video_bytes = None - try: - video_bytes = client.files.download(file=video) - except Exception: - pass - if video_bytes: - with open(out_path, "wb") as f: - f.write(video_bytes) - return True - if hasattr(video, "uri") and video.uri: - import urllib.request - uri = video.uri + ("&" if "?" in video.uri else "?") + f"key={API_KEY}" - print(f" Fetching via URI...") - urllib.request.urlretrieve(uri, out_path) - return True - return False - -def generate(): - saved = [] - for item in SHOTS: - out_path = os.path.join(OUT_DIR, f"{item['name']}.mp4") - print(f"\n[{SHOTS.index(item)+1}/{len(SHOTS)}] Generating {item['name']}...") - - done = False - for model in MODELS: - try: - print(f" Model: {model}") - op = client.models.generate_videos( - model=model, - prompt=item["prompt"], - config=types.GenerateVideosConfig( - aspect_ratio="16:9", - resolution="720p", - duration_seconds=6, - number_of_videos=1, - ), - ) - op = poll(op) - if op is None: - continue - if op.response and op.response.generated_videos: - vid = op.response.generated_videos[0].video - if download_video(vid, out_path): - size_kb = os.path.getsize(out_path) // 1024 - print(f" Saved {out_path} ({size_kb}KB)") - saved.append(out_path) - done = True - break - else: - print(f" Download failed for {model}") - else: - print(f" No video from {model}") - except Exception as e: - print(f" Error with {model}: {e}") - - if not done: - print(f" FAILED: {item['name']}") - - return saved - -def concat(clips): - if len(clips) < 2: - print("Not enough clips to concatenate.") - return - list_file = os.path.join(OUT_DIR, "concat.txt") - with open(list_file, "w") as f: - for c in clips: - f.write(f"file '{c}'\n") - print(f"\nConcatenating {len(clips)} clips into hero-reel.mp4...") - result = subprocess.run( - ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file, - "-c:v", "libx264", "-crf", "22", "-preset", "fast", - "-movflags", "+faststart", REEL_OUT], - capture_output=True, text=True - ) - if result.returncode == 0: - size_kb = os.path.getsize(REEL_OUT) // 1024 - print(f" Saved {REEL_OUT} ({size_kb}KB)") - else: - print(f" ffmpeg error: {result.stderr[-300:]}") - -if __name__ == "__main__": - clips = generate() - if clips: - concat(clips) - print(f"\nDone. {len(clips)}/5 clips generated.") - if len(clips) == 5: - print("Hero reel ready: assets/videos/hero/hero-reel.mp4") diff --git a/tools/pipeline.html b/tools/pipeline.html deleted file mode 100644 index 3cdc88f..0000000 --- a/tools/pipeline.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - -Image Gen Pipeline - - - - -

Lahr Carpet Cleaning — Image Generation Pipeline

- -

Model Stack

-
-
-
Prompt
-
gen-images-flux.py
-
28 images (16 hero + 12 svc)
-
-
-
-
API
-
ComfyUI
-
localhost:8188
-
-
-
-
UNet (model)
-
FLUX.1 Schnell
-
Q8_0 GGUF · 12GB · 12B params
-
-
-
-
Sampler
-
KSampler
-
4 steps · euler · cfg=1.0
-
-
-
-
Decode
-
FLUX AE
-
ae.safetensors · 108MB
-
-
-
-
Output
-
JPEG → WebP
-
1024×576 · q92 → q80
-
-
- -

Text Encoders

-
-
-
CLIP-L
-
clip_l.safetensors
-
235MB · short prompts
-
-
+
-
-
T5-XXL fp8
-
t5xxl_fp8_e4m3fn
-
4.6GB · long prompt understanding
-
-
-
-
Node
-
DualCLIPLoader
-
type: flux
-
-
- -

Hardware

-
-
GPU
AMD Radeon (2GB VRAM)
-
Execution
CPU only (VRAM too small)
-
Speed
~4 min / image
-
Total ETA
~1h50m for 28 images
-
- -

Model Files on Disk

-
-
UNet
flux1-schnell-Q8_0.gguf · 12GB
-
T5-XXL
t5xxl_fp8_e4m3fn.safetensors · 4.6GB
-
CLIP-L
clip_l.safetensors · 235MB
-
VAE
ae.safetensors · 108MB (official BFL)
-
- -

Generation Progress

-
-
4 / 28
-
14% — reload page to update
-
- -

Prompt Strategy

-
-
Low-angle perspective (35mm / 24mm lens specified in prompt)
-
Carpet/floor texture sharp in foreground — subject recedes into bokeh
-
Shallow depth of field + vanishing point for depth cues
-
No people, no machines, no equipment
-
Finger Lakes / upstate NY context for residential scenes
-
-
Previous model: RealVisXL V5.0 fp16 (SDXL 3.5B) — rejected: flat angles, poor depth
-
Current model: FLUX.1 Schnell (12B transformer) — better spatial understanding
-
- - - diff --git a/tools/review-all.html b/tools/review-all.html deleted file mode 100644 index 55b29cc..0000000 --- a/tools/review-all.html +++ /dev/null @@ -1,117 +0,0 @@ - - - - -Image Review — All 28 - - - - -
-

Lahr Carpet Cleaning — Image Review

-
Model: FLUX.1 Schnell Q8_0 GGUF  ·  4 steps, cfg=1.0, euler/simple  ·  1024×576 → WebP  ·  28 images total
-
- - -
- - -
- -
- -
-
-
FLUX.1 Schnell · Q8_0 GGUF · 4 steps · euler · cfg=1.0
-
-
-
-
- - - - diff --git a/tools/review-heroes.html b/tools/review-heroes.html deleted file mode 100644 index 6a3bd0e..0000000 --- a/tools/review-heroes.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - -Hero Image Review - - - -

Hero Images — RealVisXL V5.0 (15 of 16)

-
-

hero-carpet-cleaning

-

hero-upholstery

-

hero-floors

-

hero-area-rugs

-

hero-add-ons

-

hero-commercial

-

hero-offices

-

hero-vacation-rentals

-

hero-hotels

-

hero-retail

-

hero-property-management

-

hero-about

-

hero-service-area

-

hero-living-room

-

hero-clean-result

-
- - diff --git a/tools/wan-test-v2.py b/tools/wan-test-v2.py deleted file mode 100644 index cb04e57..0000000 --- a/tools/wan-test-v2.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Single test clip — corrected WanImageToVideo workflow.""" -import json, time, urllib.request, os, random - -COMFY = "http://localhost:8188" -IMAGE_PATH = "assets/images/hero/hero-carpet-cleaning.webp" -OUT_DIR = "assets/videos/clips" -os.makedirs(OUT_DIR, exist_ok=True) - - -def upload_image(image_path): - fname = os.path.basename(image_path) - with open(image_path, "rb") as f: - img_data = f.read() - boundary = "----FormBoundary123456" - body = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="image"; filename="{fname}"\r\n' - f"Content-Type: image/webp\r\n\r\n" - ).encode() + img_data + f"\r\n--{boundary}--\r\n".encode() - req = urllib.request.Request( - f"{COMFY}/upload/image", data=body, - headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, - ) - with urllib.request.urlopen(req) as resp: - result = json.loads(resp.read()) - print(f" uploaded: {result['name']}") - return result["name"] - - -def build_workflow(image_name, prompt, frames=25): - # WanImageToVideo is a conditioning node, NOT a sampler. - # outputs: [0]=positive CONDITIONING, [1]=negative CONDITIONING, [2]=latent LATENT - # start_image is optional IMAGE — anchors first frame. - return { - "1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "Wan2.2-TI2V-5B-Q4_K_M.gguf"}}, - "2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", "type": "wan"}}, - "3": {"class_type": "VAELoader", "inputs": {"vae_name": "wan_2.1_vae.safetensors"}}, - "4": {"class_type": "LoadImage", "inputs": {"image": image_name}}, - "5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}}, - "6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": "blur, low quality, distortion, text, watermark, people, jitter"}}, - "7": { - "class_type": "WanImageToVideo", - "inputs": { - "positive": ["5", 0], - "negative": ["6", 0], - "vae": ["3", 0], - "start_image": ["4", 0], - "width": 832, "height": 480, "length": frames, "batch_size": 1, - }, - }, - "8": { - "class_type": "KSampler", - "inputs": { - "model": ["1", 0], - "positive": ["7", 0], - "negative": ["7", 1], - "latent_image": ["7", 2], - "seed": 42, "steps": 20, "cfg": 6.0, - "sampler_name": "uni_pc", "scheduler": "simple", "denoise": 1.0, - }, - }, - # VAEDecodeTiled handles video (5D) latents — VAEDecode only handles images (4D) - "9": {"class_type": "VAEDecodeTiled", "inputs": {"samples": ["8", 0], "vae": ["3", 0], "tile_size": 512, "overlap": 64, "temporal_size": 64, "temporal_overlap": 8}}, - "10": { - "class_type": "SaveAnimatedWEBP", - "inputs": {"images": ["9", 0], "filename_prefix": "wan_test", "fps": 12, "lossless": False, "quality": 85, "method": "default"}, - }, - } - - -def queue_prompt(workflow): - data = json.dumps({"prompt": workflow}).encode() - req = urllib.request.Request(f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read())["prompt_id"] - - -def wait_for_result(prompt_id, timeout=1800): - start = time.time() - while time.time() - start < timeout: - with urllib.request.urlopen(f"{COMFY}/history/{prompt_id}") as resp: - hist = json.loads(resp.read()) - if prompt_id in hist: - entry = hist[prompt_id] - if entry.get("status", {}).get("status_str") == "error": - print(f" ERROR: {entry['status'].get('messages', '')}") - return None - for node_out in entry.get("outputs", {}).values(): - if "gifs" in node_out: - return node_out["gifs"] - if "images" in node_out: - return node_out["images"] - elapsed = int(time.time() - start) - print(f" waiting... {elapsed}s", flush=True) - time.sleep(15) - return None - - -def download_output(vid_info, out_path): - fname = vid_info["filename"] - subfolder = vid_info.get("subfolder", "") - img_type = vid_info.get("type", "output") - url = f"{COMFY}/view?filename={fname}&subfolder={subfolder}&type={img_type}" - with urllib.request.urlopen(url) as resp: - data = resp.read() - with open(out_path, "wb") as f: - f.write(data) - print(f" saved: {out_path} ({len(data)//1024}KB)") - - -print("[TEST v2] WanImageToVideo → KSampler → VAEDecode → SaveAnimatedWEBP") -image_name = upload_image(IMAGE_PATH) -workflow = build_workflow( - image_name, - "slow dolly forward across clean plush cream carpet, gentle camera push toward the far wall, warm afternoon light, cinematic smooth motion", - frames=9, -) -prompt_id = queue_prompt(workflow) -print(f" queued: {prompt_id}") -results = wait_for_result(prompt_id) -if results: - download_output(results[0], f"{OUT_DIR}/test-clip-01.webp") - print("SUCCESS") -else: - print("FAILED")