Compare commits

...

2 Commits

Author SHA1 Message Date
Concept Agent 53aacf9999 update to booking link and also finalized and approved 2026-05-21 20:04:03 +02:00
Concept Agent 2e9329b1f4 security hardening 2026-05-21 18:51:47 +02:00
18 changed files with 66 additions and 2162 deletions
+3
View File
@@ -6,3 +6,6 @@ build/
*.log
__pycache__/
*.pyc
.claude/
.planning/
.tools/
+26 -4
View File
@@ -1,14 +1,36 @@
Options -Indexes
RewriteEngine On
# Deny sensitive files
<FilesMatch "\.(py|yml|yaml|md|log|sh|env|conf|dockerfile)$">
# Security headers
<IfModule mod_headers.c>
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"
</IfModule>
# Block server version
ServerSignature Off
# Deny sensitive file types
<FilesMatch "\.(py|pyc|yml|yaml|md|log|sh|env|conf|dockerfile|bak|backup|sql|key|pem|planning|cpanel)$">
Order allow,deny
Deny from all
</FilesMatch>
# Deny tools directory
RewriteRule ^tools/ - [F,L]
# Block root-level dev files
<FilesMatch "^(Dockerfile|docker-compose\.yml|README\.md|\.cpanel\.yml)$">
Order allow,deny
Deny from all
</FilesMatch>
# 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
-124
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

+37 -9
View File
@@ -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 (hidden, but belt-and-suspenders)
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;
}
}
-83
View File
@@ -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:]}")
-84
View File
@@ -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()
-230
View File
@@ -1,230 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Lahr Clip Browser</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0a0a0b; color: #eee; font-family: Inter, sans-serif; padding: 24px; }
h1 { font-size: 20px; color: #e8291b; margin-bottom: 6px; }
p.sub { color: #888; font-size: 13px; margin-bottom: 24px; }
.layout { display: grid; grid-template-columns: 1fr 340px; gap: 24px; }
#clip-list { display: flex; flex-direction: column; gap: 10px; }
.clip-item {
display: flex; align-items: center; gap: 12px;
background: #161618; border: 1px solid #2a2a2e; border-radius: 8px;
padding: 10px 12px; cursor: grab; user-select: none;
transition: border-color 0.15s;
}
.clip-item:hover { border-color: #e8291b; }
.clip-item.dragging { opacity: 0.4; }
.clip-item.drag-over { border-color: #e8291b; background: #1f1012; }
.drag-handle { color: #555; font-size: 18px; flex-shrink: 0; cursor: grab; }
.clip-thumb { width: 120px; height: 68px; object-fit: cover; border-radius: 4px; flex-shrink: 0; background: #222; }
.clip-info { flex: 1; min-width: 0; }
.clip-name { font-size: 13px; font-weight: 600; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.clip-size { font-size: 11px; color: #666; margin-top: 2px; }
.clip-actions { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
.btn-preview { background: #1e1e22; border: 1px solid #333; color: #ccc; padding: 5px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; }
.btn-preview:hover { border-color: #e8291b; color: #e8291b; }
.btn-remove { background: transparent; border: 1px solid #333; color: #555; padding: 5px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; }
.btn-remove:hover { border-color: #e8291b; color: #e8291b; }
.sidebar { position: sticky; top: 24px; }
.preview-box { background: #161618; border: 1px solid #2a2a2e; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.preview-box h3 { font-size: 13px; color: #888; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
#preview-video { width: 100%; border-radius: 6px; background: #000; }
#preview-name { font-size: 12px; color: #666; margin-top: 8px; text-align: center; }
.reel-box { background: #161618; border: 1px solid #2a2a2e; border-radius: 8px; padding: 16px; }
.reel-box h3 { font-size: 13px; color: #888; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
#reel-order { font-size: 11px; color: #666; line-height: 1.8; margin-bottom: 14px; max-height: 220px; overflow-y: auto; }
.btn-build { width: 100%; background: #e8291b; color: #fff; border: none; padding: 12px; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; }
.btn-build:hover { background: #c72216; }
#build-output { margin-top: 12px; font-size: 11px; color: #888; background: #0d0d0f; border-radius: 4px; padding: 10px; display: none; white-space: pre-wrap; }
.unused-section { margin-top: 32px; }
.unused-section h2 { font-size: 14px; color: #555; margin-bottom: 12px; }
#unused-list { display: flex; flex-direction: column; gap: 8px; }
.unused-item { display: flex; align-items: center; gap: 12px; background: #111; border: 1px solid #1e1e22; border-radius: 8px; padding: 8px 12px; }
.unused-item .clip-name { font-size: 12px; color: #666; }
.btn-add { background: #1e1e22; border: 1px solid #333; color: #888; padding: 5px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; margin-left: auto; }
.btn-add:hover { border-color: #4a9; color: #4a9; }
</style>
</head>
<body>
<h1>Lahr Clip Browser</h1>
<p class="sub">Drag clips to reorder. Click Preview to watch. Remove clips from reel. Build Reel when ready.</p>
<div class="layout">
<div>
<div id="clip-list"></div>
<div class="unused-section">
<h2>Available (not in reel)</h2>
<div id="unused-list"></div>
</div>
</div>
<div class="sidebar">
<div class="preview-box">
<h3>Preview</h3>
<video id="preview-video" controls></video>
<div id="preview-name">Click Preview on any clip</div>
</div>
<div class="reel-box">
<h3>Current Reel Order</h3>
<div id="reel-order"></div>
<button class="btn-build" onclick="buildReel()">Build Reel</button>
<div id="build-output"></div>
</div>
</div>
</div>
<script>
const BASE = '/assets/videos/hero/clips/';
const ALL_CLIPS = [
{ name: 'v3-shot-01', label: 'v3 · Family enters door, pans to carpet', size: '5.2MB' },
{ name: 'v3-shot-02', label: 'v3 · Wine spill on sofa close-up', size: '6.0MB' },
{ name: 'v3-shot-03', label: 'v3 · Dirty stained carpet close-up', size: '3.3MB' },
{ name: 'v3-shot-04', label: 'v3 · Clean bright sofa pullback', size: '5.6MB' },
{ name: 'v3-shot-05', label: 'v3 · Office lobby carpet pan', size: '4.7MB' },
{ name: 'v3-shot-06', label: 'v3 · Living room clean carpet pan', size: '4.9MB' },
{ name: 'v3-shot-07', label: 'v3 · Restaurant carpet glide', size: '3.1MB' },
{ name: 'v2-shot-01-door-entry', label: 'v2 · Door entry muddy boots', size: '2.2MB' },
{ name: 'v2-shot-02-mud-on-carpet', label: 'v2 · Mud boots on carpet floor level', size: '4.1MB' },
{ name: 'v2-shot-03-stain-on-chair', label: 'v2 · Stain on chair close-up', size: '3.3MB' },
{ name: 'v2-shot-04-extraction-carpet', label: 'v2 · Extraction machine on carpet', size: '4.2MB' },
{ name: 'v2-shot-05-clean-stairs', label: 'v2 · Clean bright staircase', size: '5.6MB' },
{ name: 'v2-shot-06-office', label: 'v2 · Bright office carpet', size: '4.7MB' },
{ name: 'v2-shot-07-restaurant', label: 'v2 · Restaurant carpet', size: '4.7MB' },
{ name: 'shot-01-door-opens-trimmed', label: 'v1 · Door opens (trimmed 2.5s)', size: '525KB' },
{ name: 'shot-01-wide-room', label: 'v1 · Wide room establishing', size: '2.6MB' },
{ name: 'shot-02-pan-to-stains', label: 'v1 · Pan to stains / muddy shoes', size: '2.8MB' },
{ name: 'shot-02-staircase', label: 'v1 · Staircase', size: '1.4MB' },
{ name: 'shot-03-stain-closeup', label: 'v1 · Stain close-up', size: '4.7MB' },
{ name: 'shot-03-technician', label: 'v1 · Technician', size: '1.6MB' },
{ name: 'shot-04-extraction-carpet', label: 'v1 · Extraction carpet (clean reveal)', size: '6.2MB' },
{ name: 'shot-04-extraction-closeup', label: 'v1 · Extraction close-up', size: '2.0MB' },
{ name: 'shot-05-clean-reveal', label: 'v1 · Clean reveal', size: '2.2MB' },
{ name: 'shot-05-extraction-couch', label: 'v1 · Extraction couch', size: '3.5MB' },
{ name: 'shot-06-extraction-stairs', label: 'v1 · Extraction stairs', size: '5.4MB' },
{ name: 'shot-07-office-entryway', label: 'v1 · Office entryway', size: '5.9MB' },
{ name: 'shot-08-showroom', label: 'v1 · Showroom', size: '4.1MB' },
{ name: 'shot-09-technician-unloading', label: 'v1 · Technician unloading van', size: '3.7MB' },
{ name: 'shot-01-door-opens', label: 'v1 · Door opens (full 6s)', size: '1.5MB' },
];
// Default reel = current v3 set
let reelClips = ALL_CLIPS.slice(0, 7).map(c => c.name);
function getClip(name) { return ALL_CLIPS.find(c => c.name === name); }
function render() {
const list = document.getElementById('clip-list');
list.innerHTML = '';
reelClips.forEach((name, idx) => {
const clip = getClip(name);
if (!clip) return;
const div = document.createElement('div');
div.className = 'clip-item';
div.draggable = true;
div.dataset.name = name;
div.innerHTML = `
<span class="drag-handle">⠿</span>
<video class="clip-thumb" src="${BASE}${name}.mp4" muted preload="metadata"></video>
<div class="clip-info">
<div class="clip-name">${idx+1}. ${clip.label}</div>
<div class="clip-size">${clip.size}</div>
</div>
<div class="clip-actions">
<button class="btn-preview" onclick="preview('${name}', '${clip.label}')">Preview</button>
<button class="btn-remove" onclick="remove('${name}')">Remove</button>
</div>`;
div.addEventListener('dragstart', dragStart);
div.addEventListener('dragover', dragOver);
div.addEventListener('drop', drop);
div.addEventListener('dragend', dragEnd);
list.appendChild(div);
});
const unused = ALL_CLIPS.filter(c => !reelClips.includes(c.name));
const ulist = document.getElementById('unused-list');
ulist.innerHTML = '';
unused.forEach(clip => {
const div = document.createElement('div');
div.className = 'unused-item';
div.innerHTML = `
<div class="clip-name">${clip.label} <span style="color:#444">(${clip.size})</span></div>
<button class="btn-add" onclick="addToReel('${clip.name}')">+ Add</button>`;
ulist.appendChild(div);
});
const orderEl = document.getElementById('reel-order');
orderEl.innerHTML = reelClips.map((n,i) => {
const c = getClip(n);
return `<div>${i+1}. ${c ? c.label : n}</div>`;
}).join('');
}
function preview(name, label) {
const v = document.getElementById('preview-video');
v.src = BASE + name + '.mp4';
v.play();
document.getElementById('preview-name').textContent = label;
}
function remove(name) {
reelClips = reelClips.filter(n => n !== name);
render();
}
function addToReel(name) {
reelClips.push(name);
render();
}
let dragSrc = null;
function dragStart(e) { dragSrc = this; this.classList.add('dragging'); }
function dragOver(e) { e.preventDefault(); document.querySelectorAll('.clip-item').forEach(el => el.classList.remove('drag-over')); this.classList.add('drag-over'); }
function drop(e) {
e.preventDefault();
if (dragSrc === this) return;
const fromName = dragSrc.dataset.name;
const toName = this.dataset.name;
const fi = reelClips.indexOf(fromName);
const ti = reelClips.indexOf(toName);
reelClips.splice(fi, 1);
reelClips.splice(ti, 0, fromName);
render();
}
function dragEnd() { document.querySelectorAll('.clip-item').forEach(el => el.classList.remove('dragging','drag-over')); }
async function buildReel() {
const out = document.getElementById('build-output');
out.style.display = 'block';
out.textContent = 'Sending to server...';
const resp = await fetch('/tools/build-reel-api.py', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clips: reelClips })
});
if (!resp.ok) {
// Fallback: show the ffmpeg command to copy
const lines = reelClips.map(n => `file 'assets/videos/hero/clips/${n}.mp4'`).join('\n');
out.textContent = 'Server not available. Run this manually:\n\n' +
'# 1. Save as concat.txt:\n' + lines + '\n\n' +
'# 2. Run ffmpeg:\n' +
'ffmpeg -y -f concat -safe 0 -i concat.txt -c:v libx264 -crf 22 -preset fast -movflags +faststart assets/videos/hero/hero-reel.mp4';
} else {
const data = await resp.json();
out.textContent = data.message || 'Done.';
}
}
render();
</script>
</body>
</html>
-60
View File
@@ -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.")
-292
View File
@@ -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)
-226
View File
@@ -1,226 +0,0 @@
"""
Lahr Carpet Cleaning — Location page generator.
Creates /locations/<slug>/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'<span class="text-accent">{accent_word}</span> {rest}'.strip()
return f""" <div class="service-card">
<div class="service-image"><img src="{svc['img']}" alt="{svc['name']} in {city_name}, NY"></div>
<h3 class="heading-3">{h3}</h3>
<div class="text-block-4">{svc['sub']}</div>
<p class="paragraph-2">{svc['desc']}</p>
<a href="{svc['slug']}" class="btn btn-primary">Learn More</a>
</div>"""
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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carpet Cleaning in {name}, NY | Lahr Carpet Cleaning</title>
<meta name="description" content="Professional carpet, upholstery, and floor cleaning in {name}, NY. Lahr Carpet Cleaning serves {county} and the Finger Lakes region. Call 315-719-1218.">
<link rel="stylesheet" href="/assets/css/styles.css?v=5">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body>
<header class="header" id="header">
<div class="container"><div id="site-nav"></div></div>
</header>
<section class="page-hero" style="background-image: url('{hero_img}');">
<div class="container">
<div class="page-hero-content">
<span class="hero-eyebrow">{county} &mdash; Finger Lakes</span>
<h1 class="hero-title">{name},<br><span class="text-accent">NY</span></h1>
<p>{note}</p>
<div class="hero-actions">
<a href="/contact/" class="btn btn-primary btn-large">Book Now</a>
<a href="tel:315-719-1218" class="btn btn-ghost btn-large"><i class="fas fa-phone"></i> 315-719-1218</a>
</div>
</div>
</div>
</section>
<section class="services-overview">
<div class="container">
<div class="section-header">
<span class="section-label">{name}, NY</span>
<h2 class="section-title">Services in {name}</h2>
<p class="section-subtitle">We serve {name} and the surrounding {county} communities. Call to confirm availability for your address.</p>
</div>
<div class="services-grid">
{cards}
</div>
</div>
</section>
<section class="cta-banner">
<div class="cta-content">
<h2 class="heading-4"><strong>Serving </strong><span class="text-accent"><strong>{name}</strong></span></h2>
<p class="paragraph-4">Call 315-719-1218 or submit the form for a free estimate in {name}, NY.</p>
<a href="/contact/" class="btn btn-primary">Get a Free Estimate</a>
</div>
</section>
<div id="site-footer"></div>
<script src="/assets/js/components.js"></script>
<script src="/assets/js/main.js"></script>
</body>
</html>
"""
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'<span class="text-accent">{accent_word}</span> {rest}, NY'
else:
h3 = f'<span class="text-accent">{name}</span>, NY'
city_cards.append(f""" <div class="service-card">
<div class="service-image"><img src="{img}" alt="{name} NY"></div>
<h3 class="heading-3">{h3}</h3>
<div class="text-block-4">{county}</div>
<p class="paragraph-2">{note}</p>
<a href="/locations/{slug}/" class="btn btn-primary">View Services</a>
</div>""")
cards_html = "\n".join(city_cards)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Areas | Lahr Carpet Cleaning | Finger Lakes, NY</title>
<meta name="description" content="Lahr Carpet Cleaning serves Waterloo, Geneva, Seneca Falls, Canandaigua, Penn Yan, and 16 more cities across the Finger Lakes region. Call 315-719-1218.">
<link rel="stylesheet" href="/assets/css/styles.css?v=5">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body>
<header class="header" id="header">
<div class="container"><div id="site-nav"></div></div>
</header>
<section class="page-hero" style="background-image: url('/assets/images/hero/hero-technician.jpg');">
<div class="container">
<div class="page-hero-content">
<span class="hero-eyebrow">Finger Lakes Region</span>
<h1 class="hero-title">Service<br><span class="text-accent">Areas</span></h1>
<p>We clean carpets, upholstery, rugs, and hard floors across 21 cities in Upstate New York. Select your city below.</p>
<div class="hero-actions">
<a href="/contact/" class="btn btn-primary btn-large">Book Now</a>
<a href="tel:315-719-1218" class="btn btn-ghost btn-large"><i class="fas fa-phone"></i> 315-719-1218</a>
</div>
</div>
</div>
</section>
<section class="services-overview">
<div class="container">
<div class="section-header">
<span class="section-label">Where We Work</span>
<h2 class="section-title">Cities We Serve</h2>
<p class="section-subtitle">Based in Waterloo, NY. We travel throughout Seneca, Ontario, Yates, Wayne, and Cayuga counties.</p>
</div>
<div class="services-grid">
{cards_html}
</div>
</div>
</section>
<section class="cta-banner">
<div class="cta-content">
<h2 class="heading-4"><strong>Not sure if we cover your area?</strong></h2>
<p class="paragraph-4">Call 315-719-1218 or submit the form and we will confirm availability for your address.</p>
<a href="/contact/" class="btn btn-primary">Get a Free Estimate</a>
</div>
</section>
<div id="site-footer"></div>
<script src="/assets/js/components.js"></script>
<script src="/assets/js/main.js"></script>
</body>
</html>
"""
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.")
-179
View File
@@ -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))
-223
View File
@@ -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")
-220
View File
@@ -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")
-151
View File
@@ -1,151 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Image Gen Pipeline</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d0d0d; color: #e0e0e0; font-family: monospace; padding: 32px; }
h1 { font-size: 14px; color: #aaa; margin-bottom: 28px; letter-spacing: 1px; text-transform: uppercase; }
h2 { font-size: 11px; color: #666; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 16px; }
.pipeline {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 40px;
flex-wrap: wrap;
gap: 0;
}
.node {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 14px 18px;
min-width: 160px;
text-align: center;
}
.node .label { font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
.node .name { font-size: 13px; color: #e0e0e0; font-weight: bold; }
.node .sub { font-size: 10px; color: #888; margin-top: 4px; }
.node.highlight { border-color: #4a9eff; background: #0f1f33; }
.node.highlight .name { color: #4a9eff; }
.arrow { color: #444; font-size: 20px; padding: 0 8px; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 40px; }
.card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 6px; padding: 14px; }
.card .k { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
.card .v { font-size: 13px; color: #ddd; }
.card .v.ok { color: #4caf50; }
.card .v.warn { color: #ff9800; }
.progress { margin-bottom: 40px; }
.bar-wrap { background: #1a1a1a; border-radius: 4px; height: 24px; margin-bottom: 8px; overflow: hidden; }
.bar { height: 100%; background: #4a9eff; border-radius: 4px; display: flex; align-items: center; padding-left: 10px; font-size: 11px; color: #fff; transition: width 0.3s; }
.pct { font-size: 11px; color: #666; }
.log { background: #0a0a0a; border: 1px solid #222; border-radius: 6px; padding: 16px; font-size: 11px; line-height: 1.8; color: #888; }
.log .ok { color: #4caf50; }
.log .done { color: #aaa; }
</style>
</head>
<body>
<h1>Lahr Carpet Cleaning — Image Generation Pipeline</h1>
<h2>Model Stack</h2>
<div class="pipeline">
<div class="node">
<div class="label">Prompt</div>
<div class="name">gen-images-flux.py</div>
<div class="sub">28 images (16 hero + 12 svc)</div>
</div>
<div class="arrow"></div>
<div class="node">
<div class="label">API</div>
<div class="name">ComfyUI</div>
<div class="sub">localhost:8188</div>
</div>
<div class="arrow"></div>
<div class="node highlight">
<div class="label">UNet (model)</div>
<div class="name">FLUX.1 Schnell</div>
<div class="sub">Q8_0 GGUF · 12GB · 12B params</div>
</div>
<div class="arrow"></div>
<div class="node">
<div class="label">Sampler</div>
<div class="name">KSampler</div>
<div class="sub">4 steps · euler · cfg=1.0</div>
</div>
<div class="arrow"></div>
<div class="node">
<div class="label">Decode</div>
<div class="name">FLUX AE</div>
<div class="sub">ae.safetensors · 108MB</div>
</div>
<div class="arrow"></div>
<div class="node">
<div class="label">Output</div>
<div class="name">JPEG → WebP</div>
<div class="sub">1024×576 · q92 → q80</div>
</div>
</div>
<h2>Text Encoders</h2>
<div class="pipeline" style="margin-bottom:40px">
<div class="node">
<div class="label">CLIP-L</div>
<div class="name">clip_l.safetensors</div>
<div class="sub">235MB · short prompts</div>
</div>
<div class="arrow">+</div>
<div class="node highlight">
<div class="label">T5-XXL fp8</div>
<div class="name">t5xxl_fp8_e4m3fn</div>
<div class="sub">4.6GB · long prompt understanding</div>
</div>
<div class="arrow"></div>
<div class="node">
<div class="label">Node</div>
<div class="name">DualCLIPLoader</div>
<div class="sub">type: flux</div>
</div>
</div>
<h2>Hardware</h2>
<div class="grid">
<div class="card"><div class="k">GPU</div><div class="v warn">AMD Radeon (2GB VRAM)</div></div>
<div class="card"><div class="k">Execution</div><div class="v warn">CPU only (VRAM too small)</div></div>
<div class="card"><div class="k">Speed</div><div class="v">~4 min / image</div></div>
<div class="card"><div class="k">Total ETA</div><div class="v">~1h50m for 28 images</div></div>
</div>
<h2>Model Files on Disk</h2>
<div class="grid">
<div class="card"><div class="k">UNet</div><div class="v ok">flux1-schnell-Q8_0.gguf · 12GB</div></div>
<div class="card"><div class="k">T5-XXL</div><div class="v ok">t5xxl_fp8_e4m3fn.safetensors · 4.6GB</div></div>
<div class="card"><div class="k">CLIP-L</div><div class="v ok">clip_l.safetensors · 235MB</div></div>
<div class="card"><div class="k">VAE</div><div class="v ok">ae.safetensors · 108MB (official BFL)</div></div>
</div>
<h2>Generation Progress</h2>
<div class="progress">
<div class="bar-wrap"><div class="bar" style="width:14%">4 / 28</div></div>
<div class="pct">14% — reload page to update</div>
</div>
<h2>Prompt Strategy</h2>
<div class="log">
<div class="ok">Low-angle perspective (35mm / 24mm lens specified in prompt)</div>
<div class="ok">Carpet/floor texture sharp in foreground — subject recedes into bokeh</div>
<div class="ok">Shallow depth of field + vanishing point for depth cues</div>
<div class="ok">No people, no machines, no equipment</div>
<div class="ok">Finger Lakes / upstate NY context for residential scenes</div>
<br>
<div class="done">Previous model: RealVisXL V5.0 fp16 (SDXL 3.5B) — rejected: flat angles, poor depth</div>
<div class="done">Current model: FLUX.1 Schnell (12B transformer) — better spatial understanding</div>
</div>
</body>
</html>
-117
View File
@@ -1,117 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Image Review — All 28</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d0d0d; color: #ddd; font-family: monospace; padding: 24px; }
.header { margin-bottom: 24px; }
.header h1 { font-size: 13px; color: #888; text-transform: uppercase; letter-spacing: 1px; }
.header .meta { font-size: 11px; color: #444; margin-top: 6px; }
.section-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 2px; margin: 28px 0 12px; border-bottom: 1px solid #1a1a1a; padding-bottom: 8px; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 8px; }
.card { background: #141414; border: 1px solid #222; border-radius: 6px; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
.card:hover { border-color: #4a9eff; }
.card img { width: 100%; height: 160px; object-fit: cover; display: block; }
.card .info { padding: 10px; }
.card .name { font-size: 11px; color: #aaa; margin-bottom: 6px; font-weight: bold; }
.card .prompt { font-size: 10px; color: #555; line-height: 1.6; }
.card .tag { display: inline-block; font-size: 9px; background: #1a2a1a; color: #4a9eff; padding: 2px 6px; border-radius: 3px; margin-bottom: 6px; }
/* lightbox */
.lb { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.93); z-index: 100; padding: 32px; }
.lb.open { display: flex; gap: 32px; align-items: flex-start; }
.lb img { max-height: 80vh; max-width: 60vw; object-fit: contain; border-radius: 6px; flex-shrink: 0; }
.lb .lb-info { flex: 1; }
.lb .lb-name { font-size: 13px; color: #eee; margin-bottom: 12px; }
.lb .lb-model { font-size: 10px; color: #4a9eff; margin-bottom: 16px; }
.lb .lb-prompt { font-size: 12px; color: #aaa; line-height: 1.8; }
.lb .lb-close { position: absolute; top: 16px; right: 24px; font-size: 24px; color: #555; cursor: pointer; }
.lb .lb-close:hover { color: #eee; }
</style>
</head>
<body>
<div class="header">
<h1>Lahr Carpet Cleaning — Image Review</h1>
<div class="meta">Model: FLUX.1 Schnell Q8_0 GGUF &nbsp;·&nbsp; 4 steps, cfg=1.0, euler/simple &nbsp;·&nbsp; 1024×576 → WebP &nbsp;·&nbsp; 28 images total</div>
</div>
<div class="section-label">Hero Images (16)</div>
<div class="grid" id="heroes"></div>
<div class="section-label">Service Cards (12)</div>
<div class="grid" id="services"></div>
<div class="lb" id="lb" onclick="closeLB()">
<img id="lb-img" src="">
<div class="lb-info">
<div class="lb-name" id="lb-name"></div>
<div class="lb-model">FLUX.1 Schnell · Q8_0 GGUF · 4 steps · euler · cfg=1.0</div>
<div class="lb-prompt" id="lb-prompt"></div>
</div>
<div class="lb-close"></div>
</div>
<script>
const HEROES = [
{ file: "hero-carpet-cleaning.webp", 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" },
{ file: "hero-stairs.webp", 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" },
{ file: "hero-upholstery.webp", 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" },
{ file: "hero-floors.webp", 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" },
{ file: "hero-area-rugs.webp", 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" },
{ file: "hero-add-ons.webp", 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" },
{ file: "hero-commercial.webp", 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" },
{ file: "hero-offices.webp", 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" },
{ file: "hero-vacation-rentals.webp", 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" },
{ file: "hero-hotels.webp", 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" },
{ file: "hero-retail.webp", 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" },
{ file: "hero-property-management.webp", 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" },
{ file: "hero-about.webp", 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" },
{ file: "hero-service-area.webp", 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" },
{ file: "hero-living-room.webp", 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" },
{ file: "hero-clean-result.webp", 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" },
];
const SERVICES = [
{ file: "carpet-cleaning.webp", 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" },
{ file: "stairs-cleaning.webp", 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" },
{ file: "upholstery-cleaning.webp", 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" },
{ file: "floor-cleaning.webp", 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" },
{ file: "area-rug-cleaning.webp", 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" },
{ file: "add-ons.webp", 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" },
{ file: "commercial-overview.webp", 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" },
{ file: "vacation-rentals.webp", 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" },
{ file: "office-spaces.webp", 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" },
{ file: "hotels-inns.webp", 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" },
{ file: "retail-showrooms.webp", 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" },
{ file: "property-management.webp", 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" },
];
function openLB(src, name, prompt) {
document.getElementById('lb-img').src = src;
document.getElementById('lb-name').textContent = name;
document.getElementById('lb-prompt').textContent = prompt;
document.getElementById('lb').classList.add('open');
}
function closeLB() { document.getElementById('lb').classList.remove('open'); }
function buildCards(items, containerId, basePath) {
const el = document.getElementById(containerId);
items.forEach(item => {
const src = basePath + item.file;
const name = item.file.replace('.webp','');
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `<img src="${src}" loading="lazy"><div class="info"><div class="tag">FLUX.1 Schnell</div><div class="name">${name}</div><div class="prompt">${item.prompt.substring(0,120)}…</div></div>`;
card.onclick = () => openLB(src, name, item.prompt);
el.appendChild(card);
});
}
buildCards(HEROES, 'heroes', '../assets/images/hero/');
buildCards(SERVICES, 'services', '../assets/images/services/');
</script>
</body>
</html>
-35
View File
@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hero Image Review</title>
<style>
body { background: #111; color: #eee; font-family: sans-serif; padding: 20px; }
h1 { font-size: 16px; margin-bottom: 20px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.item { }
.item img { width: 100%; height: 200px; object-fit: cover; border-radius: 4px; }
.item p { font-size: 12px; margin: 4px 0; color: #aaa; }
</style>
</head>
<body>
<h1>Hero Images — RealVisXL V5.0 (15 of 16)</h1>
<div class="grid">
<div class="item"><img src="../assets/images/hero/hero-carpet-cleaning.jpg"><p>hero-carpet-cleaning</p></div>
<div class="item"><img src="../assets/images/hero/hero-upholstery.jpg"><p>hero-upholstery</p></div>
<div class="item"><img src="../assets/images/hero/hero-floors.jpg"><p>hero-floors</p></div>
<div class="item"><img src="../assets/images/hero/hero-area-rugs.jpg"><p>hero-area-rugs</p></div>
<div class="item"><img src="../assets/images/hero/hero-add-ons.jpg"><p>hero-add-ons</p></div>
<div class="item"><img src="../assets/images/hero/hero-commercial.jpg"><p>hero-commercial</p></div>
<div class="item"><img src="../assets/images/hero/hero-offices.jpg"><p>hero-offices</p></div>
<div class="item"><img src="../assets/images/hero/hero-vacation-rentals.jpg"><p>hero-vacation-rentals</p></div>
<div class="item"><img src="../assets/images/hero/hero-hotels.jpg"><p>hero-hotels</p></div>
<div class="item"><img src="../assets/images/hero/hero-retail.jpg"><p>hero-retail</p></div>
<div class="item"><img src="../assets/images/hero/hero-property-management.jpg"><p>hero-property-management</p></div>
<div class="item"><img src="../assets/images/hero/hero-about.jpg"><p>hero-about</p></div>
<div class="item"><img src="../assets/images/hero/hero-service-area.jpg"><p>hero-service-area</p></div>
<div class="item"><img src="../assets/images/hero/hero-living-room.jpg"><p>hero-living-room</p></div>
<div class="item"><img src="../assets/images/hero/hero-clean-result.jpg"><p>hero-clean-result</p></div>
</div>
</body>
</html>
-125
View File
@@ -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")