lotstretcher automates the workflow of turning a vehicle listing from a dealership website into polished, platform-specific social-media posts and high-converting marketing visuals -- stretching one photo shoot into every format a dealer's lot needs to sell a car online.
Given a vehicle detail page (VDP) URL, it:
- Scrapes vehicle specs, full-resolution photo galleries, window stickers, and Carfax history via Playwright (bypassing Cloudflare challenges).
- Processes photos through a computer vision pipeline — zero-shot CLIP classification, rembg (BiRefNet) background removal, perceptual-hash deduplication (including a cross-vehicle photo cache that reuses an already-processed cutout when the dealer's CDN serves the same manufacturer stock photo to multiple listings), dealer banner cropping, and super-resolution upscaling.
- Composes hero collages, solo framed images with branded borders, and animated MP4 video carousels — hero-video rendering runs across parallel workers with GPU-accelerated compositing, and overlaps with the next vehicle's scrape/CV work during a batch sync.
- Generates ready-to-copy-paste posts for Facebook Marketplace, Instagram, and Threads — each tailored to platform character limits, hashtag strategies, and preview rules.
- Tracks processed inventory via a persistent manifest, making daily syncs fast and incremental.
lotstretcher was originally built for Tomball Ford (a DealerInspire CMS site) and is designed to work plug-and-play with any DealerInspire-powered dealership, and with any other dealership CMS via a pluggable extractor architecture.
Vehicle photo/video merchandising software is a real market with real incumbents (CarCutter, Impel/SpinCar, Spyne, PBS Systems, and others), and most of it is built and priced for multi-rooftop dealer groups — contract terms, sales-call-gated pricing, brand-compliance dashboards across locations. That's a different customer than the single-rooftop independent dealer this project was actually built for.
The closer comparison is the narrow pure-play tier — standalone AI background-removal/compositing tools — where pricing is public: as of this research (2026-09), one such vendor lists $0.20–$0.40 per photo, no subscription. lotstretcher does the same core job (photo classification, background removal, hero/video compositing, post copy) self-hosted, for the marginal cost of your own compute after setup — no per-photo fee, no monthly subscription, no contract, and your dealership's photos never leave your own server. It's free and open source (MIT), not a crippled trial of a paid product.
That's the tradeoff, stated plainly: you run it yourself (a GPU with 4+ GB VRAM recommended, see below), and in exchange you own the pipeline outright.
lotstretcher is one pipeline, one app, and one set of options, reachable two ways. Which one you want depends on how many vehicles you have and where the work should happen, not on paying for a better version. There is no paid tier, no feature gate and no crippled free build: it is all MIT, in this one repository.
The browser client in web/ is the same files in both places. lotstretcher.org serves it
from a CDN; lotstretcher-serve mounts the identical directory. There is no second build and no "server
edition" of the UI, so a fix lands in both at once and a control added to one appears in the other
because it is the other.
| The website | Self-hosted | |
|---|---|---|
| For | one car, right now, from a phone | a whole lot, on a schedule |
| Input | photos, camera, pasted image URLs, window sticker PDF, a listing address or VIN decoded on the device | that, plus scraping and full inventory sync |
| Library | open a listings folder from disk and browse it | the configured library is served, always there |
| Where it runs | entirely in your browser; photos never upload | your machine or your server |
| Costs | nothing to you, nothing to host | your own compute |
| Install | none | Python 3.11+, a GPU helps |
What differs between them is capabilities, not code. The app asks its host /capabilities and
unlocks what that host can actually do, so gated features live in the same source as everything else and
are simply switched off when a browser is on its own. It asks "can I scrape?", never "am I the paid
version?".
lotstretcher-serve # API + the browser client at http://127.0.0.1:8000/
lotstretcher-serve --no-app # API onlyRunning it this way unlocks what a browser cannot do alone. The app hands a composition to the server when, and only when, you switch on a control that needs one:
| endpoint | what it does |
|---|---|
GET /capabilities |
what this host can do, so the app unlocks accordingly |
GET /assets |
the background and border library, filling those selects |
POST /compose |
composes one cutout with the server's assets and GPU |
POST /scrape |
reads one vehicle page with the server's headless browser and returns the record lotstretcher <url> would build; the A listing or VIN sheet reads the page through it on a host that reports scrape |
GET /library, GET /library/{bucket}/{folder}/{file} |
the listings library (--library, default ~/Documents/listings, the CLI's default --out), for the app's Library pane |
GET /library/status |
the last run, recent run history, fetched and delisted counts, running jobs |
POST /library/{bucket}/{folder}/recompose |
rebuild one vehicle's bundle in place with the app's current options; the same path as the recompose CLI (library_ops.py), returned as a job to poll at GET /jobs/{id} |
POST /library/{bucket}/{folder}/rescrape |
fetch the vehicle's page again and run the whole pipeline into the library, as lotstretcher <url> --force would; one at a time |
POST /library/sync |
one inventory sync cycle into the library, as inventory-sync would, when a dealer inventory URL is configured |
POST /library/{bucket}/{folder}/delist |
stamp the vehicle delisted_at, the same mark inventory-sync makes; nothing is deleted |
POST /library/{bucket}/{folder}/delete |
remove the folder and its manifest entry for good; the body must repeat the folder name |
A lotstretcher-config.json at the library root is read at startup as the dealer config, so the library
carries its own inventory URL, greeting, address and city tags. Classifiers are warmed in the background
at startup so the first re-scrape does not wait on a model load.
The CLI's flags and the app's controls meet in one place: library_ops.hero_options_from_controls() builds
the pipeline's options from the app's control values, and cli.py converts its own flags to those same
values before calling it. A control-parity test also fails if cli.py defines a flag it never reads,
which is how --no-spotlight, --margin-frac, --video-duration and --video-fps were found to be
accepted and ignored, and fixed.
The Library pane browses what the pipeline has produced: every vehicle folder under new/ and
used/, its hero stills, framed and interior sets, clips, the three posts, and the record it was built
from, with a button to load the originals back in and rerun it with today's options. The layout it reads
is library in the shared spec, the same names vehicle_pipeline writes. Against your server the
configured library is simply there; on lotstretcher.org, or anywhere, you can open a listings folder
from disk and the same pane reads it in the browser. One reader, two sources, and
tests/test_library_parity.py feeds both the same synthetic library and
requires the same index back.
POST /compose receives the cutout, never the source photograph. Matting already happened on
your device, so the original image stays there; only the cut-out vehicle travels, and only when you
ask for something the server can do and a browser cannot. Anything it could not honour, such as a
border missing from its library, comes back on an X-Lotstretcher-Warning header rather than being
silently dropped.
Why the website can't scrape, and what it does instead. A browser can't read another site's HTML (cross-origin rules), and dealer sites sit behind Cloudflare challenges that a serverless function can't pass either: measured, not assumed. But that only blocks discovering photo URLs, never using them. Dealer image CDNs serve cross-origin fine, so anything you hand the browser it can process. Scraping lives on the self-hosted surface because that's the surface that can actually do it.
The website's A listing or VIN sheet is one field that takes the vehicle page address or a VIN, and
fetches nothing: a dealer page sits behind a bot challenge that only a real browser passes (a plain
fetch of one gets a 403, which is why the self-hosted server drives a headless browser). What the
address and the VIN say on their own is decoded on the device from tables in the spec
(vin in shared/pipeline-spec.json): the address's slug gives the year, make and model, the VIN
its check digit, model year and manufacturer. lotstretcher/vin.py and
web/public/js/pipeline/vin.js are the same decoder in both languages, and
tests/test_vin_parity.py holds them to the same answers, including
every address in the local listings library when one is present. Model and trim are encoded per
manufacturer and need NHTSA's database, which is why the slug carries them here and the price,
mileage and photos stay yours to add. Scan VIN reads the door-jamb barcode through the camera on
the device (the browser's own barcode detector where it exists, else the vendored ZXing reader in
web/public/vendor/zxing, MIT) and takes the same route. Against a self-hosted server the same field
reads the whole page. The sheet also takes a saved copy of the page (Ctrl+S, "Webpage, HTML only") or its pasted
source, for the dealer's own photos and price without a server: listing.js is a port of
normalize_vehicle(), and tests/test_listing_parity.py runs both
on one fixture under node and compares every field.
Two implementations (Python and JavaScript) are unavoidable. Two specifications are not.
shared/pipeline-spec.json holds every constant both sides need: output
formats and sizes, video timing, the palette bands and colour words, glow colours, the cutout quality
gates. Both read it, through src/lotstretcher/spec.py and web/public/js/spec.js.
The text side is held to the same rule, and more simply: the posts are built once, in the Rust core
(core/src/copy.rs), and facebook_post.py, social_post.py and web/public/js/pipeline/copy.js are
hosts that hand it the vehicle and the dealer boilerplate.
tests/test_copy_parity.py holds the core, natively and in the wasm build
under node, to the posts the Python produced before it was deleted. The app's Settings (the gear in the
header) hold the same boilerplate dealer_config does: greeting, address, city hashtags, filled once
and kept on the device.
tests/test_spec_parity.py enforces it, including the cases the
indirection alone can't cover: it compares the remaining Python literals against the spec, greps the
JavaScript for any module that goes back to hardcoding, and fails outright if a format size reappears as
a literal in options.js.
Sharing the algorithms themselves, not just their constants, is done with a Rust crate in
core/ compiled twice: to a native library the CLI and server load through ctypes
(src/lotstretcher/core.py), and to wasm32 the browser loads (web/public/js/core.js, built by
web/build-core.sh into web/public/core/, which is committed so the static site needs no toolchain).
The spec is embedded in the crate at build time, so a constant changed there changes in the core.
What is in it now: the whole still compositing and the whole of both videos. Gradient backdrops
from the vehicle's colours (the palette logic included), the adaptive spotlight, placement and all five
layouts, glow, alpha compositing, and the bordered path: window detection in the frame's alpha, collision
of every placement against the border art, and the frame as the top layer. On both surfaces compose_hero
is a call into the core with a JSON request and one byte arena, and the same seed produces the same bytes
natively and in the browser. Every video frame comes from the core too (render_frame: backdrop,
spotlight, cars at rectangles with alphas, glow, border), and so does the choreography: the conveyor's
bar-locked schedule, pan geometry, beat pulse and transitions (carousel_plan / carousel_frame), and
the spin's placement, hold and cross-dissolve schedule (spin_plan / spin_frame). The CLI's
hero_video.py and spin.py and the browser's video.js supply only cutouts, labels and a clock, so the
browser's conveyor is the CLI's edit.
A host can also keep images resident in the core (retain / release) and name them by id: the video
hosts retain their cutouts, scaled cars, layers and flag frames once, so a frame request carries no
pixels in, only the finished frame out, and a retained car's glow halo is blurred once per clip rather
than once per frame. Measured on a real five-shot vehicle at 1254²: a glowing conveyor frame 108 ms to
21 ms, byte-identical; the whole 26 s clip's frames 84 s to 42 s. In the browser a 720² three-shot conveyor
renders in about 4 s for a 9.6 s clip. Interior photos are the core's too (enhance_interior: the
capped white balance from bright near-neutral pixels, then the highlight-holding exposure lift), so the
browser now writes the same interior/ set the CLI's bundle carries, at the photo's own size. The wheel
money shot's mask arithmetic is the core's as well (mask_stats, cutout_from_mask, duplicate_score:
bounds, 8-connected blobs, the dominance, width and angle gates, the crop, the duplicate check); the
models that make the masks, CLIPSeg and SAM2, stay in the CLI, which is why wheel shots are a server
feature the app's capabilities list says the browser lacks. The Monroney sticker parser is the core's
(parse_sticker, panel_split_x): the CLI hands it pdftotext -bbox words and the browser hands it
pdf.js words, and both get the same record, checked on every real sticker in the operator's library
(114 of them, byte-identical to the Python parser they replaced) and, in the browser, against the same
fixtures. The post copy is the core's as well (build_posts: the Marketplace post, the Threads post inside
its cap, the Instagram caption, the hashtags and the audit notes), with facebook_post.py, social_post.py
and copy.js reduced to hosts that hand in the vehicle and the dealer boilerplate. Everything the operator
ruled into the core is in it; models stay in ONNX Runtime on both sides.
tests/test_core_parity.py holds the core to the Python it replaced, and is
skipped with a message when the core has not been built (cargo build --release in core/).
The CLI (the default — one-shot scripts and cron) and an opt-in server mode exposing a CarCutter-API-shaped HTTP surface — see Server Mode below. The point isn't cloning CarCutter; it's that a lot of dealer-software integrations already speak that shape, so switching the base URL to a self-hosted, fully open alternative is a realistic option rather than a rewrite. Nothing about your inventory photos leaves your own server in either mode.
- GPU (Recommended): NVIDIA GPU with 4+ GB VRAM (CUDA support).
inventory-sync's batch pipeline (parallel hero-video rendering across CPU workers, GPU-accelerated compositing, overlapped scrape/CV work across vehicles) measures ~35–50 seconds of effective wall-clock time per vehicle end-to-end on real inventory, not counting network waits on the dealer site. - CPU (Fallback): CPU-only execution is fully supported via PyTorch and ONNX Runtime CPU fallbacks, but processing time will be several minutes per vehicle due to deep-learning models (CLIP, BiRefNet, SwinIR/Real-ESRGAN, SAM2) and CPU-side video compositing.
Dependency & CUDA Note: This package requires
onnxruntime-gpu(not the CPU-onlyonnxruntimepackage) forrembg's background-removal model to actually run on the GPU — the CPU package silently falls back to ~10x slower CPU inference with no error. It also installstorch,torchvision,open_clip_torch,rembg, andspandrel. Ensure your NVIDIA drivers and CUDA runtime are compatible with your installed PyTorch wheel. If running on a system without a GPU, ONNX Runtime and PyTorch will automatically execute on the CPU.
- Python 3.11+
- FFmpeg: Required for generating animated MP4 hero videos (
hero-video).- Ubuntu/Debian:
sudo apt update && sudo apt install -y ffmpeg - macOS:
brew install ffmpeg - Arch Linux:
sudo pacman -S ffmpeg
- Ubuntu/Debian:
Because lotstretcher is distributed as an open-source source repository, clone the repository and install it inside a Python virtual environment:
# 1. Clone the repository
git clone https://github.com/asuramaya/lotstretcher.git
cd lotstretcher
# 2. Create and activate a virtual environment (Python 3.11+)
python3 -m venv .venv
source .venv/bin/activate
# 3. Upgrade pip and install lotstretcher in editable mode
pip install --upgrade pip
pip install -e .
# Optional: Install development and test dependencies
pip install -e ".[dev]"
# 4. Install Playwright browser binaries (Chromium)
playwright install chromiumProcess a single vehicle listing:
lotstretcher "https://www.tomballford.com/vehicle/1HGCY1F24SA035661/Used-2025-Honda-Accord-Tomball-TX/"Output lands in the current working directory under new/ or used/, bucketed by condition. Each vehicle folder contains structured data, source assets, and finished marketing deliverables:
<year>-<make>-<model>-<trim>-<stock>/
├── details.json # Full scraped Vehicle record
├── images/
│ ├── exterior/ # Originals + CLIP-classified exterior photos
│ │ ├── cutout/ # Transparent background cutouts
│ │ └── wheels/ # Extracted wheel close-ups
│ └── interior/ # White-balance-corrected interior photos
├── window-sticker.pdf # Original Monroney window sticker (if found)
├── window-sticker.json # Parsed window sticker options & equipment
└── bundle/
├── hero.png # Composed hero collage (1:1 square for Facebook Marketplace)
├── hero-portrait.png # 9:16 portrait (Stories, Reels, TikTok)
├── hero-horizontal.png # 16:9 horizontal (YouTube, landscape feed)
├── hero-video.mp4 # Animated video carousel (1:1 square)
├── hero-video-vertical.mp4 # 9:16 vertical video (Instagram Reels / TikTok / Shorts)
├── hero-video-horizontal.mp4 # 16:9 widescreen video (YouTube)
├── framed/ # Every exterior cutout framed individually
├── window-sticker-a.png # Readable window sticker slide A
├── window-sticker-b.png # Readable window sticker slide B
├── facebook.txt # Ready-to-paste Facebook Marketplace listing text
├── instagram.txt # Hook-first caption + targeted hashtags
└── threads.txt # Character-capped Threads post
lotstretcher uses a flexible configuration hierarchy:
CLI Arguments > Environment Variables (LOTSTRETCHER_*) > JSON Config File (--dealer-config / LOTSTRETCHER_CONFIG) > Default Values
Create a dealer-config.json file for your dealership:
{
"dealer_name": "Apex Ford of Austin",
"dealer_greeting": "Ask for Alex in Sales!",
"dealer_address": "4500 Motorway Blvd, Austin, TX 78701",
"city_tags": ["Austin", "AustinCars", "ATXAuto", "TexasTrucks"],
"default_border_tag": "dealer-frame",
"inventory_url": "https://www.apexfordaustin.com/inventory/all-vehicles/",
"inventory_urls": {
"used": "https://www.apexfordaustin.com/inventory/used-vehicles/",
"new": "https://www.apexfordaustin.com/inventory/new-vehicles/",
"all": "https://www.apexfordaustin.com/inventory/all-vehicles/"
}
}inventory_urls is what inventory-sync --scope <name> reads, so you can run inventory-sync --scope used instead of retyping your dealer's full URL every time. Scope names are entirely up to you -- they're just keys in this object, not a fixed set lotstretcher understands. There's deliberately no built-in default for these (unlike dealer_name/dealer_greeting/etc): an unconfigured --scope fails loudly rather than silently pointing at whichever dealership this tool happened to ship with example values for.
Pass it on any command with --dealer-config or by setting the LOTSTRETCHER_CONFIG environment variable:
lotstretcher <vdp-url> --dealer-config /path/to/dealer-config.jsonYou can also configure lotstretcher directly using environment variables (ideal for Docker or CI/CD pipelines):
| Environment Variable | Description | Default |
|---|---|---|
LOTSTRETCHER_CONFIG |
Path to a JSON configuration file | None |
LOTSTRETCHER_DEALER_NAME |
Dealership name used in copy & captions | Tomball Ford |
LOTSTRETCHER_DEALER_GREETING |
Greeting line in Facebook / social posts | Ask for us at the front desk! |
LOTSTRETCHER_DEALER_ADDRESS |
Physical address included in listing copy | 22702 TX-249, Tomball, TX 77375 |
LOTSTRETCHER_CITY_TAGS |
Comma-separated hashtags for social copy | Tomball,TomballCars,Houston,HoustonCars |
LOTSTRETCHER_DEFAULT_BORDER_TAG |
Default border tag from assets/manifest.json |
tomball-dealer-frame |
LOTSTRETCHER_INVENTORY_URL |
Full inventory search URL for batch crawling | None |
LOTSTRETCHER_INVENTORY_URL_<SCOPE> |
Per-scope inventory URL, e.g. LOTSTRETCHER_INVENTORY_URL_USED for --scope used |
None |
LOTSTRETCHER_LISTINGS_ROOT |
Default output directory for listings | ./listings or current directory |
See .env.example and lotstretcher-config.json.example for template files.
lotstretcher composites vehicle cutouts onto branded border frames. These assets live in assets/ and are cataloged in assets/manifest.json:
assets/borders/: Frame PNGs with transparent center windows (standard size: 1254×1254). The frame sits on top of the cutouts so logos and phone numbers stay sharp.assets/backgrounds/: Background textures and graphics (used when not using the per-vehicle gradient generator).assets/video/&assets/audio/: Looping video backdrops and background tracks for animated hero videos.
- Design a 1254×1254 PNG with a transparent center where the vehicle should appear.
- Save it to
assets/borders/my-dealer-frame.png. - Register it in
assets/manifest.json:{ "name": "My Dealership Frame", "file": "borders/my-dealer-frame.png", "tags": ["dealer-frame", "custom"] } - Use
--border "My Dealership Frame"or set"default_border_tag": "custom"in your configuration.
The app's "Stock" tiles on lotstretcher.org come from the same
manifest. An entry carrying "studio": true is exported by
python3 web/build-studio.pyinto web/public/studio/ (backgrounds as WebP at most 2048 px, frames
as lossless WebP so the window keeps its alpha) with a manifest the app
reads at start. The site composes those on the visitor's own machine;
there is no server behind lotstretcher.org, and the whole library is a
few hundred KB. Entries without the tag, such as a dealer's private
frames, stay on your machine and appear in the app only against your
own server, which composes them. The exported files are committed, so
run the build and commit its output when you add or retag an asset.
A frame is drawn for one shape, and the still formats have several. The
format always decides the canvas; --frame-fit decides how the frame
meets it: fit (the default) keeps the whole frame centred with the
backdrop filling the rest, fill covers the canvas and crops the frame's
edges, stretch pulls the frame to the canvas shape. The app's Frame
group has the same lever, previewed live per format.
Frame art is drawn for one shape. --frame-style line draws a rounded
line inset from the edge at whatever size the canvas is, so it fits a
square, a portrait and a horizontal exactly, on the stills and on the clip.
--frame-color is white, black or paint (the vehicle's own colour,
from the listing's name or sampled off the cutout); --frame-weight
is the line's weight as a share of the shorter side. In the app it is
the "Studio line" tile of the Frame picker, with the colour and weight
levers beside it. Frame art, when given, wins.
--backdrop sweep draws a studio cyclorama in the vehicle's own
colours: the wall darkens toward the top, brightens to a lit floor
line, and the floor falls off below it with a pool of light where the
car stands. --backdrop generic is the seeded hue bands the app has
had all along, now on the command line too; --backdrop vehicle is
the default gradient. A photo (--photo-background) wins over any of
them. The clip holds one sweep frame behind the conveyor, as it holds
a photo. In the app it is the Backdrop picker's Sweep tile, drawn by
the core on the preview's subject.
--backdrop-color "#rrggbb" puts the hue bands or the sweep in a
colour of your own instead of the paint's (a colour word works too);
the vehicle backdrop is computed from the paint and ignores it. The
colour goes through the same backdrop-safe treatment the paint does,
so a loud hex lands as the deeper tone a car can sit on. In the app
it is the Colour picker under the Backdrop tiles, shown for those two
tiles only, with a Paint button back to the computed colour.
The Studio is laid out as an editor: the stage in the middle, in the
chosen format's own shape, a rail of tools beside it (Looks, then the
spec's groups, then Output) and one tool's levers open at a time. The
shapes are chosen under the stage, one chip per still or video format:
tap one to see it in its own shape, tick it to make it. Output holds
the run's estimates and the same run as a command line; what this host
can do lives in Settings. Looks is the first tool: a grid of looks, each a named set of light and
frame values defined once in the spec (controls.looks): Clean,
Showroom (shadow and reflection), Gallery (a white line and a soft
shadow) and Paint line (the vehicle's colour on the line, a glow and
the floor). Tapping one sets those levers; every lever stays yours to
move after, and the chip releases the moment a value differs. On the
command line --look NAME applies the same values to lotstretcher,
recompose and hero-video; any flag you also type wins over the
look, so --look showroom --shadow-strength 0.9 is the showroom with
a darker shadow.
--shadow sets the vehicle down: a soft shadow read off the cutout's
own silhouette, squashed flat about its lowest opaque row and blurred,
so the car reads as standing on the backdrop rather than floating over
it. --shadow-strength is its darkness, 0 to 1 (default 0.5). It is
under every car on the stills and the clip, fades with a car
mid-dissolve, and sits beneath the glow. In the app it is the Light
group's "Ground shadow" toggle with its darkness slider; recompose
and hero-video take the same two flags.
--reflection adds a floor reflection: the vehicle mirrored below its
own contact line and faded out over the top of it, as a glossy studio
floor gives; --reflection-strength is its opacity at the floor line
(default 0.35). It sits under the shadow, so the two together read as
one floor. The app's Light group has the same toggle and slider.
The core draws text (core/src/text.rs, the studio's Lato Bold, OFL) so the CLI's hero and the app's preview carry the same words at the same places. Three pieces stack in a corner and the vehicle is laid out clear of them; with a frame they sit inside its window, never on its art.
lotstretcher URL --title vehicle --price-badge --text-line "Ask for Alex" \
--text-position br --text-color white --text-size 0.05--title vehicle writes year make model trim from the listing;
--title custom writes --title-text. --price-badge puts the post's
own resolved price in a pill (MSRP for new, the listed price for used).
--text-color paint puts the vehicle's own colour on the badge, from
the listing's colour name or sampled off the cutout. The app's Text
group is the same seven levers, previewed live as you type. recompose takes them too. The clip carries the same text on
every frame, with the conveyor laid out in the room beside it, and sits
on the same backdrop as the stills: --photo-background puts the photo
behind the clip too, unless --video-flag-background asks for the
flag video instead.
Installing lotstretcher registers 8 CLI commands (a 9th, lotstretcher-serve, is opt-in -- see Server Mode below):
Scrapes the VDP, runs CV segmentation, composes images/videos, and generates copy.
# Single vehicle
lotstretcher https://www.yourdealer.com/vehicle/12345/Used-2023-Ford-F-150/
# Batch from a URL list file
lotstretcher --file urls.txt --out ~/Documents/listings
# Crawl an inventory listing page (expands all matching VDPs)
lotstretcher "https://www.yourdealer.com/inventory/all-vehicles/?make=Ford&model=Mustang" --out ~/Documents/listings
# Dry-run: preview what a listing URL would expand to without scraping
lotstretcher "https://www.yourdealer.com/inventory/all-vehicles/" --dry-run
# Run full inventory sync (processes new vehicles and flags delisted ones)
lotstretcher "https://www.yourdealer.com/inventory/all-vehicles/" --out ~/Documents/listings --syncNo VDP at all? lotstretcher also accepts local photo folders in place of a URL -- a trade-in walked
around with a phone, an auction photo dump, a DAM export, anything with no dealership webpage to
scrape. Drop photos in a folder, optionally add a vehicle.json with whatever Vehicle fields
you know (year/make/model/price/description/... -- see src/lotstretcher/local_source.py), and it runs
through the exact same CLIP/cutout/compose/copy pipeline as a scraped vehicle. Mix local folders
and URLs freely in the same command; each is auto-detected by whether the argument is an existing
directory.
This is also exactly what the browser client does — same input, same pipeline, different surface (see One App, Two Hosts).
# my-trade-in/01.jpg, 02.jpg, ... + an optional vehicle.json
lotstretcher ./my-trade-in --out ~/Documents/listings
# vehicle.json is optional -- without one you still get hero/video/cutouts,
# just thinner post copy (no year/make/model to write sentences about)
echo '{"year": "2023", "make": "Ford", "model": "F-150", "trim": "Raptor"}' > ./my-trade-in/vehicle.jsonA dedicated CLI designed for cron jobs. It crawls live inventory, skips already-processed vehicles via manifest.json, processes new arrivals, and marks delisted inventory.
inventory-sync --inventory-url "https://www.yourdealer.com/inventory/all-vehicles/" --out ~/Documents/listings
# Also run the local-vision front-seat-config check (needs `ollama serve` with
# gemma4:e2b pulled) -- batched at the end of the run so the model loads once,
# not once per vehicle
inventory-sync --inventory-url "..." --out ~/Documents/listings --vision-seat-check
# Once your dealer-config.json has an "inventory_urls" map (see Configuration
# above), skip retyping the URL entirely:
inventory-sync --scope used
inventory-sync --scope newSafety valves: a sync refuses to flag anything as delisted if the listing crawl came back incomplete (--headed to debug why), or if more than 30% of previously-seen active inventory would suddenly be flagged missing in one cycle (almost always a bad/narrow crawl, not real turnover). Pass --confirm-mass-delist to override either one if you're sure it's genuine.
"Just Arrived, Photos Coming Soon" placeholders: a vehicle whose gallery is nothing but the dealer's stock "photos coming soon" graphic is recognized as junk (perceptual-hash matched, see imaging/templates/) and left with zero photos rather than a fake cutout of the placeholder. It's marked photos_pending in the manifest instead of "done," so the next sync automatically retries it — no manual re-run needed once real photos go up.
Fast copy re-generation from existing details.json files without re-scraping or re-running computer vision models.
# Rebuild copy for all listings in a directory
posts ~/Documents/listings
# Rebuild copy for a single vehicle folder
posts ~/Documents/listings/used/2023-Ford-F-150-Raptor-PFA30435/Re-renders bundle/hero.png and bundle/framed/*.png using already-extracted cutouts. Ideal when you update your dealership logo or frame.
recompose ~/Documents/listings --border "Generic Dealer Frame"--resweep self-heals photos that were misfiled into images/interior/ under an older classifier or
threshold: it re-runs current classification against every interior photo already on disk and promotes
any that the pipeline would now call exterior — without re-scraping. It's conservative by design (a
promotion only happens if the photo also produces a valid cutout AND survives the same gallery-consistency
check --prune-foreign runs in reverse), so most candidates get correctly left alone; use --dry-run
first to see what it would do.
recompose ~/Documents/listings --resweep --interiors --dry-run # preview
recompose ~/Documents/listings --resweep --interiors # apply
# then, for any vehicle it reports as changed:
hero-video ~/Documents/listings/used/2023-Ford-.../ --format allCompose custom hero layouts for a single vehicle with specific layouts (quad, corners, trio, split), backgrounds, and lighting effects.
# List available backgrounds, borders, and layouts
compose --list-assets
# Compose with a specific layout and background
compose ~/Documents/listings/used/2023-Ford-F-150-Raptor-PFA30435/ --layout quad --background american-flagGenerates animated MP4 video carousels with multi-angle cutouts timed to background music and video loops.
hero-video ~/Documents/listings/used/2023-Ford-F-150-Raptor-PFA30435/Builds a CSV/TSV inventory feed from vehicles already scraped, in the shape third-party listing platforms (Meta/Facebook Automotive Inventory Ads, and by convergent convention most others) expect to ingest — VIN, price, mileage, colors, the dealer's own real photo URLs, one row per vehicle. There's no open, universal inventory feed standard (every DMS integration is a bespoke, gatekept vendor relationship), but exporting a feed needs nobody's permission — this just re-shapes data lotstretcher already has.
export-feed ~/Documents/listings --out feed.csv
export-feed ~/Documents/listings/used --out used-feed.csv --condition used --tsvRead src/lotstretcher/export_feed.py's module docstring before relying on this in production: the exact field
names were cross-confirmed from several independent secondary sources rather than pulled directly from
Meta's own (JS-rendered, partially login-gated) spec page — validate the output against your destination
platform's own feed validator, and adjust FEED_COLUMNS/vehicle_to_row() if reality differs; it's
deliberately kept as one flat mapping table, not scattered logic.
Turns a vehicle's own real angle-labeled cutouts (front → front_3q → side → rear_3q → rear) into a rotating
video via cross-dissolve interpolation between them — the used-inventory-appropriate alternative to a
licensed generic 3D CAD model (right for new inventory, wrong for used: it shows the SKU, not the actual
physical car). This is not true 3D reconstruction — that's confirmed research-stage industry-wide, not
something any vendor ships at scale today. It's the same class of trick behind commercial "360 spin"
products: synthesize a smooth rotation from a handful of real photos instead of requiring a turntable shoot.
lotstretcher typically has 5 real angles per vehicle already — more anchor frames than the reference product's own
4-photo baseline. See src/lotstretcher/imaging/compose/spin.py's module docstring for the full scope/honesty
notes (one side's half-turn, not a full 360; classical cross-dissolve, not a trained view-synthesis model).
spin-video ~/Documents/listings/used/2023-Ford-F-150-Raptor-PFA30435/lotstretcher-serve is a 9th command, opt-in and separate from the 8 above — a long-running HTTP service instead
of a one-shot script, exposing lotstretcher's own classify/cutout/composite pipeline behind a
CarCutter-API-shaped surface: same request/response shapes
where it matters for drop-in compatibility, self-hosted, and every line of processing code is readable
(unlike the closed SaaS API it mirrors). It's a different front door onto the same imaging pipeline the CLI
uses, not a parallel implementation.
# Install the server extra (kept separate so `pip install -e .` for CLI-only use never pulls in a web framework)
pip install -e ".[server]"
lotstretcher-serve --data-dir ~/.lotstretcher-server --port 8000Core endpoints implemented (see src/lotstretcher/server/app.py's module docstring for the full scope, including
what's accepted-for-compatibility but not yet behavioral, e.g. cut_type="blur"):
POST /vehicle/composition/single-segment— sync, one image in, one composited result out.POST /vehicle/image/submission— async, up to 60 images, optionalwebhook_urlcallback.GET /vehicle/image/status/GET /vehicle/image/result— poll a submission.POST /vehicle/submission,POST /vehicle/list,GET /vehicle/status,DELETE /vehicle/delete,GET /vehicle/shotlist— a light vehicle registry (SQLite-backed), bookkeeping only.
curl -X POST http://127.0.0.1:8000/vehicle/composition/single-segment \
-H "Content-Type: application/json" \
-d '{"image_url": "https://example.com/car.jpg", "cut_type": "complete"}'The included daily_sync.sh wraps inventory-sync for unattended cron use: starts Ollama if configured
(for --vision-seat-check), writes a timestamped log per run, and prunes logs older than 30 days.
Configure it entirely via environment variables (defaults shown, all optional):
export LOTSTRETCHER_REPO_DIR="/path/to/lotstretcher" # default: this deployment's checkout
export LOTSTRETCHER_SCOPE="used" # a name from dealer-config.json's inventory_urls
# ...or set LOTSTRETCHER_INVENTORY_URL directly to skip scope resolution entirely
export LOTSTRETCHER_LISTINGS_ROOT="/path/to/listings"
export LOTSTRETCHER_CONFIG="/path/to/dealer-config.json" # dealer_name/greeting/inventory_urls/etc
export LOTSTRETCHER_LOG_DIR="/path/to/sync-logs" # default: ~/.local/sync-logsAdd a cron job (crontab -e) to run daily at 6:00 AM:
0 6 * * * /path/to/lotstretcher/daily_sync.sh >> /var/log/lotstretcher-sync.log 2>&1(daily_sync.sh writes its own per-run log under LOTSTRETCHER_LOG_DIR regardless -- the redirect above just
catches anything printed before that log file exists, e.g. a missing .venv.)
lotstretcher comes out-of-the-box with support for DealerInspire CMS platforms. Adding support for another CMS (e.g. Dealer.com, DealerOn, CDK Global) is simple thanks to the pluggable extractor registry in scrape.py.
Most automotive CMS platforms embed vehicle data directly into a JavaScript variable on the page for Google Tag Manager / analytics. lotstretcher searches the rendered HTML for this marker and extracts the balanced JSON object.
# in scrape.py or an extension script:
from lotstretcher.scrape import register_extractor, normalize_vehicle, Vehicle
# 1. Define the JavaScript marker and validation function
CUSTOM_CMS_MARKER = "window.digitalData = "
def custom_cms_validator(data: dict) -> bool:
# Verify that the parsed JSON contains expected vehicle keys
return bool(data and "vehicle" in data and "vin" in data["vehicle"])
# 2. Register the extractor
register_extractor("custom_cms", CUSTOM_CMS_MARKER, custom_cms_validator)
# 3. Update normalize_vehicle() to map your CMS fields to the Vehicle dataclass
# (e.g. mapping data['vehicle']['vin'] -> Vehicle.vin)- Symptom:
RuntimeError: Cloudflare challenge did not clear in time. - Cause: The dealership website is presenting an interactive Cloudflare turnstile or rate-limiting requests.
- Solution:
lotstretcherautomatically retries with exponential backoff and passes realistic browser headers.- Avoid running dozens of parallel threads against the same domain simultaneously.
- Test the URL in standard non-headless Chromium to verify your IP is not banned.
- Symptom:
playwright._impl._errors.Error: Executable doesn't exist at ... - Solution: Run
playwright install chromiuminside your virtual environment. If running on headless Linux, also install OS dependencies withplaywright install-deps chromium.
- Symptom:
torch.cuda.OutOfMemoryError: CUDA out of memory, or (specifically for hero videos)h264_nvenc'sCreateInputBuffer failed: out of memory. - Cause:
lotstretcherclears GPU cache between vehicles for the classification/cutout models, butinventory-sync's batch pipeline deliberately overlaps a vehicle's hero-video rendering (parallel worker processes, each doing GPU-accelerated compositing plus anh264_nvencencode) with the next vehicle's CLIP/rembg work on the main process — real concurrent GPU pressure, not a leak. On a smaller card (the reference numbers above assume 8+ GB) several concurrent encode sessions plus the classification models in memory at once can genuinely exceed what's free. - Solution:
- Hero-video rendering already self-heals for this specific case: if
h264_nvencfails to open,render_hero_video()automatically retries the same video with the softwarelibx264encoder rather than losing it — you'll see[fell back to libx264, GPU was too busy for h264_nvenc]in the sync log. No video is lost, it's just slower under load. - If OOM shows up elsewhere (classification/cutout, not video encoding), or the fallback itself is triggering constantly and slowing your syncs more than you'd like, lower
VIDEO_WORKERS/MAX_INFLIGHT_VEHICLESininventory_sync.py, or drop--nvenc/video_encoder="h264_nvenc"entirely to encode on CPU only. - If you have limited VRAM (< 4 GB), set
export CUDA_VISIBLE_DEVICES=""to force CPU execution mode.
- Hero-video rendering already self-heals for this specific case: if
- Symptom:
FileNotFoundError: [Errno 2] No such file or directory: 'ffmpeg'when runninghero-video. - Solution: Install FFmpeg via your system package manager (
sudo apt install ffmpegorbrew install ffmpeg).
- Symptom:
Vehicle.carfax_urlisNoneeven though a badge is present on the website. - Explanation: DealerInspire sites populate the Carfax link via an asynchronous client-side API call into the
.carfax-logoelement.fetch_rendered_html()waits up to 12 seconds for this element when "used" is in the URL. If the network is exceptionally slow, the link may not have loaded before timeout.
lotstretcher includes a complete test suite:
# 1. Run unit tests
pytest tests/
# 2. Run scrape regression tests (against offline HTML fixtures)
python scrape_regression.py
# 3. Run computer vision & imaging calibration regression
python regression.pysrc/lotstretcher/ — Core package
├── cli.py — Main CLI entry point ('lotstretcher')
├── inventory_sync.py — Cron-friendly inventory crawler and delist detector
├── compose_cli.py — Custom hero image layout composer
├── hero_video_cli.py — Animated video carousel generator
├── posts_cli.py — Fast social copy regenerator (Marketplace, IG, Threads)
├── recompose_cli.py — Recompose bundles with updated borders/assets
├── export_feed_cli.py — Inventory syndication feed generator (CSV/TSV)
├── spin_video_cli.py — Rotating spin video from real angle cutouts
├── server/ — Opt-in CarCutter-API-shaped HTTP server (`lotstretcher-serve`)
│ ├── app.py — FastAPI endpoints (vehicle registry + image processing)
│ ├── store.py — SQLite-backed vehicle/submission registry
│ └── processing.py — Shared fetch->classify->cutout->composite pipeline
├── scrape.py — Playwright scraper & pluggable CMS extraction registry
├── local_source.py — Non-scrape entry point: local photo folder -> Vehicle
├── listing.py — Inventory search page crawler (VDP link discovery)
├── photos.py — Photo gallery downloader and batch pipeline
├── window_sticker.py — Monroney window sticker PDF downloader and parser
├── facebook_post.py — Facebook Marketplace post builder
├── social_post.py — Instagram and Threads post builders
├── vehicle_pipeline.py — Unified orchestrator for end-to-end vehicle processing
├── manifest.py — Incremental fetch tracking & delist detection
├── dealer_config.py — Centralized multi-dealer configuration manager
└── imaging/ — Computer vision & media pipeline:
├── classify.py — CLIP zero-shot vehicle angle classification
├── cutout.py — rembg (BiRefNet) background removal with alpha gating
├── pipeline.py — Per-photo analysis (exterior/interior/detail scoring)
├── gallery.py — Consensus filtering (removes foreign/mismatched cars)
├── letterbox.py — Automated dealer watermark/banner cropping
├── interior.py — Interior white-balance correction & feature extraction
├── wheel.py — SAM2 / CLIPSeg wheel extraction and enhancement
├── select.py — Hero and accent photo selection algorithms
├── dedupe.py — Perceptual-hash image deduplication
├── sticker.py — Window sticker PDF text and option parser
├── upscale.py — Real-ESRGAN / SwinIR super-resolution upscaler
├── seat_vision.py — Seating configuration classifier (YOLO + CLIP)
├── palette.py — Dominant vehicle paint color extractor
└── compose/ — Composition engines for hero collages and video
assets/ — Branded borders, background images, video/audio loops
scrape_fixtures/ — Checked-in HTML fixtures for regression tests
tests/ — Pytest unit test suite
daily_sync.sh — Reference daily automation script
scrape_regression.py — Scraper regression test runner
regression.py — Computer vision & calibration regression test runner
setup.py — Package configuration & entry points
See CONTRIBUTING.md for guidelines on code style, testing, and submitting pull requests.
MIT License. See LICENSE for details.