therealworld / dynimg-module
Extends the OXID dynamic image generation to further image types and adds an optional watermark.
Package info
bitbucket.org/therealworld/dynimg-module/
Type:oxideshop-module
pkg:composer/therealworld/dynimg-module
Requires
- php: ^8.3
- ext-gd: *
- oxid-esales/oxideshop-ce: >=v7.4
- therealworld/tools-plugin: >=v3.2
Requires (Dev)
None
Suggests
- ext-gd: built with FreeType (--with-freetype) for the watermark text
Provides
None
Conflicts
None
Replaces
None
README

description
Extends the OXID dynamic image generation to further image types, adds an optional watermark, lets the merchant bring their own placeholder — and pre-generates the whole set from the command line.
New oe-console commands
- trw:dynimg:warmup [--dry-run] [--force] [--rebuild[=TIME]] [--types=a,b] [--params=a,b] [--limit=N] [--max-time=N] [--sleep=N] [--max-load=F] [--progress=N] [--samples=N] (short: t:dy:w)
- trw:dynimg:prune [--delete] [--force] [--types=a,b] (short: t:dy:p)
The short form is Symfony's built-in command abbreviation: each colon segment is shortened to the smallest prefix that is still unambiguous. Together with the commands from therealworld/clirun-plugin the second segment needs two letters, because
dbanddynimgcollide ond(t:d:mistrw:db:migrate,t:dy:wis this one).
The command lives in this module, not in the clirun-plugin: only the module knows the extra
image types, and this way the plugin does not need the module as a dependency. The command is
registered through the module's services.yaml, so it appears in oe-console as soon as the
module is installed and active in the shop.
The endpoint
The shop rewrites a request for a generated image to a PHP script, but only while the file is
not there (source/.htaccess: RewriteCond %{REQUEST_FILENAME} !-f). Once it exists, Apache
serves it statically. That script is what this module replaces:
- the shop's own
getimg.phpresolves product, manufacturer/vendor and category images - this module's
trwdynimggetimg.phpadditionally resolves the image types other modules bring along (authors, awards, sliders, news), puts the merchant's placeholder in place of the one OXID ships, and optionally watermarks everything it generates
Switching the option bTRWDynImgEnabled writes the endpoint into source/.htaccess and places
the symlink in the shop root; switching it off restores getimg.php. Nothing else has to be
edited by hand.
Options
- bTRWDynImgEnabled: use this module's endpoint instead of the shop's own one
- sTRWDynImgNopicPath: the merchant's own placeholder, relative to the shop directory
- aTRWDynImgNopicPaths: a placeholder per image type. The key is a fragment of the generated
image path (a whole branch or a single folder below it), the longest match wins, everything
else falls back to
sTRWDynImgNopicPath - bTRWDynImgWatermarkEnabled, sTRWDynImgWatermarkPath, sTRWDynImgWatermarkPosition, iTRWDynImgWatermarkMargin, iTRWDynImgWatermarkOpacity, iTRWDynImgWatermarkSize: the watermark. It is kept off the placeholder — that stands for a picture that is missing, not for one worth marking as ours. The size is the largest share of image width and height it takes; it is only ever scaled down, so a file at least that share of the biggest format keeps the same proportion everywhere
- sTRWDynImgWatermarkText, sTRWDynImgWatermarkFont, sTRWDynImgWatermarkTextColor,
iTRWDynImgWatermarkTextSize, iTRWDynImgWatermarkTextMinPx,
bTRWDynImgWatermarkTextHalo: a line of text set right of the watermark picture, sharing
its bottom edge — the shop address, for instance. Its height is a share of the scaled
picture's height, so both keep their proportion; where the line would drop below the minimum
height (thumbnails) or the whole would not fit the image, the picture goes on alone. Drawn
per image rather than baked into the PNG precisely for that: a text scaled down with the
picture cannot be left out where it stops being readable. The fonts (
mono,serif,sans, all SIL OFL) ship infonts/
Requirements check
The settings page checks the PHP it runs in — the web server's, the one serving the images —
and lists what is missing or configured so that it cannot work: GD itself, image formats GD
cannot handle, FreeType (needed for the text), a watermark that is switched on while the
endpoint is not, a watermark file that is missing (it is not part of the code, a deployment does
not bring it along), unreadable or no PNG, and a text without the watermark picture. Fields that
can have no effect in that PHP are shown locked; a locked field is not sent, so its stored value
stays as it is. trw:dynimg:warmup shows the same for the shell's PHP in its setup lines.
- aTRWDynImgConfParamToPath (invisible): the registry of the extra image types — which shop config parameter holds the size of which generated path. Modules that bring their own image types add their entries here
- aTRWDynImgDetectWithTwoOptions (invisible): path fragments whose generated url carries two options instead of three, so the master path is derived one level higher
Warmup (trw:dynimg:warmup)
Pre-generates the dynamic images, so the image endpoint no longer has to. Every image warmed here is one PHP process a visitor — or a crawler — does not start any more.
Nothing about the image set is hardcoded in the command:
- the generator the shop's
.htaccesscurrently points at is instantiated, so the warmed files are byte-identical to the served ones, watermark included - its map "config parameter → generated path pattern" is read from that instance. For this
module's endpoint the map already carries the extra image types merged in from
aTRWDynImgConfParamToPath - the sizes come from wherever they are configured — the shop config for the shop's own image
types, the module configuration for the ones a module brings along — and the quality from
sDefaultImageQuality. Together that is exactly whatPictureHandler::getPicUrl()puts into the url
Since OXID 7 a module setting lives in the module configuration only.
getConfigParam()never sees it (Config::loadVarsFromDb()readsoxmodule LIKE ''plus the active theme), and the core's ownisValidPath()looks the size up inoxconfig, where it is not either. Both sides are handled here:DynImgSizeResolverresolves the size for the parameters named inaTRWDynImgConfParamToPath, and this module's generator accepts such a path. Without that, a module image type is neither warmed nor generated on demand — its size folder is simply never created.
- a master folder only gets the sizes whose path pattern matches the resulting generated folder. That is the very test the endpoint applies before it serves such a path at all
- with
blConvertImagesToWebPswitched on the shop asks forname.jpg.webp, and that is what gets warmed
So a new module image type needs no change here: configure it, and it is warmed.
The run is resumable and repeatable: existing images are recognized and skipped, so a second
run costs a stat() per target and nothing else. It can be interrupted with Ctrl-C or SIGTERM —
the image being written is finished first, everything built so far stays, another run continues
there.
That also means a changed setting — the watermark switched on, another placeholder — never reaches
the images already there. --rebuild is for that. The setup lines at the top say whether the
watermark is actually applied: the endpoint skips it silently when the file is missing or no PNG
(it is not part of the code, so a deploy does not bring it along), or when the .htaccess leads to
the core endpoint.
Options
--dry-run:- only print the plan (per master folder: formats, masters, targets, present, to build) and stop. Nothing is generated
--rebuild[=TIME]:- build existing images anew as well — every one older than TIME (anything
strtotime()reads), without a value older than the start of this run. The old file is set aside and only removed once the new one is there; if the build fails, it comes back. What this run built is newer than TIME, so an interrupted rebuild resumes with--rebuild="<the time it started>"— the setup lines print exactly that
- build existing images anew as well — every one older than TIME (anything
--forceor-f:- skip the confirmation prompt (
--no-interactiondoes the same)
- skip the confirmation prompt (
--types=a,b:- only these master folders. Both a whole branch (
product) and a single folder (product/1) are accepted
- only these master folders. Both a whole branch (
--params=a,b:- only these shop config parameters (
sThumbnailsize,aDetailImageSizes, …). Unknown names abort the run with the list of the known ones
- only these shop config parameters (
--limit=N:- stop after N generated images. Caps the run, not the plan — the table keeps showing the honest total
--max-time=N:- stop after N seconds. Time spent in
--sleep/--max-loaddoes not count against it
- stop after N seconds. Time spent in
--sleep=N:- pause N milliseconds after every generated image
--max-load=F:- pause while the 1-minute load average is above F — full speed at night, out of the way during the day
--progress=N:- refresh the progress line (share, throughput, elapsed, ETA, counters) every N images,
default 25,
0switches it off. On a terminal the line rewrites itself, in a log file every interval gets its own line
- refresh the progress line (share, throughput, elapsed, ETA, counters) every N images,
default 25,
--samples=N:- how many of the noteworthy masters to list at the end, default 8
Run it as the web server user
Files and folders created here have to stay writable for the endpoint. Owned by the wrong user
they silently break the very on-demand path the warmup is supposed to relieve: the next size
nobody warmed cannot be written any more. The command says so when it finds a generated/
folder belonging to someone else, but it cannot fix it.
# preview
docker exec -u www-data <php-container> sh -c \
'cd /var/www/<shop> && vendor/bin/oe-console trw:dynimg:warmup --dry-run'
# the whole set, unattended
docker exec -u www-data <php-container> sh -c \
'cd /var/www/<shop> && vendor/bin/oe-console trw:dynimg:warmup --no-interaction'
# nightly slice: at most an hour, and out of the way while the machine is busy
vendor/bin/oe-console trw:dynimg:warmup -f --max-time=3600 --max-load=4 --sleep=20
# after switching the watermark on: everything anew, at night only
vendor/bin/oe-console trw:dynimg:warmup -f --rebuild --max-time=3600 --max-load=4
# ... and the next nights, with the start time the first run printed
vendor/bin/oe-console trw:dynimg:warmup -f --rebuild="2026-10-18 23:00:00" --max-time=3600 --max-load=4
# one image type only
vendor/bin/oe-console trw:dynimg:warmup -f --types=product/1 --params=sThumbnailsize
What the result table means
- built: the image is on disk, the endpoint will never be asked for it again
- empty: the master carries no image data, so the target is a 0-byte file. The endpoint
produces exactly the same, and the file is deliberately kept: it is worthless, but it ends the
"PHP process per request" for that url. It wants cleaning up (
trw:clear:phantomimages), not retrying - failed: no file was produced. The reason is printed per master, the most common ones are:
- master holds no image data — a 0-byte download or a stored error page
- master holds jpeg data but is named *.png — the file is a valid image, but not the one its name promises. The generator picks its reader by extension, so such a file cannot be scaled by any endpoint: rename it or re-import the article. Typical origin is an importer that writes every downloaded picture under a fixed extension
- placeholder instead of the target — the master is missing, the endpoint answers with the
placeholder and a 404. Every request for it runs PHP;
trw:clear:phantomimagesclears the article references
Articles whose master image is missing are not covered on purpose: they never produce a file, so there is nothing to warm.
trw:clear:phantomimagescomes with therealworld/clirun-plugin, which is deliberately not a dependency of this module. The command therefore only points at it when it is actually registered — and only when the run found something it can clean up. A master merely named after a type it is not stays untouched by it.
Left over from before: a master whose content contradicted its name used to abort the generation inside the file lock with a
TypeErrorfrom the shop core — a 500 for that image url and a<target>.lckfile staying behind in the generated folder. Those stale*.lckfiles can be deleted; new ones are not created any more.
Prune (trw:dynimg:prune)
The counterpart of the warmup: it removes the generated size folders that no configured image size asks for any more. Both commands answer "which sizes are legitimate?" with the same code, so they can never disagree.
Change an image size from 400*400 to 500*500 and nothing cleans up after you:
- the endpoint only ever creates
trw:clear:phantomimagesworks per master image, and only belowproduct/— a left over size folder holds derivatives of perfectly valid masters, so it is no phantom- the shop core deletes derivatives only when the master itself is deleted
(
UtilsPic::deletePicture()globsgenerated/*/<filename>), never when a size changes
So generated/author/400_400_90/ stays behind with a full set of images nobody will ever
request again. Harmless to serve, expensive to store: on a shop with 5,000 articles a single
left over product format is easily a gigabyte. Changing sDefaultImageQuality ages every
folder at once, because the quality is part of the folder name.
What it will not do
- it never touches a picture folder for which no size is configured at all. An empty
aTRWDynImgConfParamToPathwould otherwise mean "everything is obsolete" and take every image of that type with it — and an incomplete map is exactly the state a shop is in when somebody reaches for a cleanup command. Such folders are reported as Left alone - it only ever considers folders named like a size (
width_height_quality) directly below a picture folder. Anything else belowgenerated/is none of its business - it has no
--params, on purpose: narrowing the parameters narrows the set of legitimate sizes, which is harmless while building and destructive while deleting - it deletes nothing without
--delete, and asks before it does - the master pictures are never read, let alone written — everything below
generated/can be recreated by the warmup at any time
Options
--delete:- actually remove the obsolete folders. Without it the run only reports them
--forceor-f:- skip the confirmation prompt (
--no-interactiondoes the same)
- skip the confirmation prompt (
--types=a,b:- only these picture folders, same syntax as in the warmup
# what would go, with file count and size per folder
vendor/bin/oe-console trw:dynimg:prune
# remove it, unattended
docker exec -u www-data <php-container> sh -c \
'cd /var/www/<shop> && vendor/bin/oe-console trw:dynimg:prune --delete --no-interaction'
# one image type only
vendor/bin/oe-console trw:dynimg:prune --types=product/1 --delete -f
The run can be interrupted with Ctrl-C or SIGTERM: the folder being removed is finished first, what was deleted stays deleted, another run continues there.