Watermark
Two different verbs, depending on whether your watermark is an image or text:
watermark(overlay, options)— composites a second image onto your file. Multi-input: the overlay is its own file. Image or video base.textWatermark(text, options)— stamps a text label. Single-input: the text is an option, not a file. Image base only.
There is no document watermarking — see What cannot be watermarked.
Image overlay — watermark()
The overlay is a file-node, exactly like your base file. Pass the node itself — there are no string input refs. You can process the overlay first if you want (it is a recipe like any other).
- TypeScript
- PHP
const logo = client.file('logo.png');
const result = await client
.file('photo.jpg')
.watermark(logo, { anchor: 'bottom_right', opacity: 0.6 })
.run({ maxWait: '2m' });
await result.toFile('photo-wm.jpg');
$logo = $client->file('logo.png');
$client->file('photo.jpg')
->watermark($logo, ['anchor' => 'bottom_right', 'opacity' => 0.6])
->run(maxWait: '2m')
->toFile('photo-wm.jpg');
Which media route where
watermark() routes on the base file's media — you use the same verb either
way:
| Base file | Wire operation |
|---|---|
image/jpeg, image/png, image/webp | image_watermark |
image/tiff, image/bmp | image_watermark |
video/mp4, video/webm | video_watermark — ⛔ withdrawn, see below |
image/gif | image_watermark — not currently accepted |
This table used to carry a per-MIME availability column. It was wrong —
it reported video_watermark as Beta after the contract had promoted it — and a
hand-copied availability value is wrong the moment the contract moves.
Availability travels with the contract, and getSchema() returns the
authoritative per-operation, per-media-type answer at runtime. Ask it rather than
a documentation page if you are gating a feature programmatically.
Those are the only accepted types. Other video containers (MOV and friends) are rejected locally, before upload.
video_watermark was stable and is now planned. The SDK refuses a video
base before any upload — file('clip.mp4').watermark(...) throws
GislConfigError, it does not reach the server.
⚠️ This is not the usual "coming soon". The worker exists; it cannot serve the ceiling the contract advertised, which is why the capability was pulled rather than never shipped. Re-listing at honest ceilings is expected — so treat this as a pause on a route that worked, not as a feature nobody has built.
The example below is kept because the call shape does not change when it returns. It does not work today.
A video base needs no different call — and, until the withdrawal is reversed, no call at all:
- TypeScript
- PHP
const result = await client
.file('clip.mp4')
.watermark(logo, { anchor: 'top_right', overlay_width: '20%' })
.run({ maxWait: '5m' });
$client->file('clip.mp4')
->watermark($logo, ['anchor' => 'top_right', 'overlay_width' => '20%'])
->run(maxWait: '5m');
What cannot be watermarked
Video, documents, audio and animated GIF have no watermark you can use today.
Video is the newest and the odd one out: it is withdrawn rather than unbuilt (see
the note above).
Some routes are planned in the contract (a dedicated audio-watermark operation,
and image/gif on image_watermark) but are not yet exposed here; document
watermarking has no path at all.
textWatermark() is not an alternative for these — it is image-only (unlike
watermark(), which additionally takes video, textWatermark() does not take
document, audio or video). If you have a PDF, there is currently no watermark
verb for it. When you hand watermark() a document base, the SDK rejects it
before upload and the error says exactly that.
The pre-upload gate
An unsupported base throws locally, before anything uploads — you do not pay
a round trip to discover that a PDF cannot take an image overlay. The SDK
checks as soon as it knows the base media: usually at the .watermark() call
itself, and otherwise just before upload on run() / submit() (when the base
media cannot be detected up front, such as a stream).
The error is a GislConfigError; its reason says which check failed:
reason | Cause |
|---|---|
feature_not_available | The base type is declared but not available — a video or GIF base today. |
unsupported_media | The base type has no watermark route — audio, documents, and image or video types not listed above. |
invalid_overlay_media | The overlay is not an image. |
media_unknown | The base's type cannot be detected — use a path with an extension, or a typed input. |
invalid_overlay_count | Fewer than 1 or more than 8 overlays were passed. |
overlays_count_mismatch | overlays does not have exactly one entry per overlay — see Several overlays. |
overlays_unsupported_base | overlays, or more than one overlay, on a base other than JPEG, PNG or WebP. |
Options
anchor takes a 9-grid position: top_left, top_center, top_right,
center_left, center, center_right, bottom_left, bottom_center,
bottom_right.
| Option | Type | Default | Notes |
|---|---|---|---|
anchor | anchor | bottom_right | Where the overlay sits. |
margin_x / margin_y | string | '0px' | Offset from the anchor — '40px' or '5%'. |
opacity | number | 0.5 | 0–1. |
overlay_width | string | overlay's own size | '120px' or '20%'; the aspect ratio is kept. |
Defaults are applied by the API when you omit an option. With anchor: 'center'
a non-zero margin is rejected at workflow creation (422 invalid_options) — the
centre has no edge to offset from; pick an edge or corner anchor to nudge it.
Unknown keys are rejected before upload.
Several overlays
On a JPEG, PNG or WebP base you can stack up to 8 overlays in one call: pass a
list of overlay nodes, and place each one with an overlays entry. overlays[i]
places the i-th overlay, so overlays needs exactly one entry per overlay. Later
entries are drawn on top of earlier ones.
- TypeScript
- PHP
const result = await client
.file('photo.jpg')
.watermark([client.file('logo.png'), client.file('badge.png')], {
overlays: [
{ anchor: 'top_left', margin_x: '20px', margin_y: '20px' },
{ anchor: 'bottom_right', overlay_width: '15%', opacity: 0.8 },
],
})
.run({ maxWait: '2m' });
$client->file('photo.jpg')
->watermark([$client->file('logo.png'), $client->file('badge.png')], [
'overlays' => [
['anchor' => 'top_left', 'margin_x' => '20px', 'margin_y' => '20px'],
['anchor' => 'bottom_right', 'overlay_width' => '15%', 'opacity' => 0.8],
],
])
->run(maxWait: '2m');
Use overlays or the top-level placement options, not both. Both SDKs
refuse the combination before uploading anything, with a GislConfigError
whose reason is invalid_combination (the API would answer
422 invalid_options). Likewise, more than one overlay needs overlays, with
one entry per overlay: without it the SDK refuses with overlays_count_mismatch.
Reusing an overlay
To avoid re-uploading the same overlay on every run, upload it once and
reference it by id. An uploadId input is passed through without being
uploaded again:
- TypeScript
- PHP
import { fileInput } from '@giveitsmaller/sdk';
const logo = client.file(fileInput.uploadId('file_...'));
use Gisl\Sdk\FileFirst\FileInput;
$logo = $client->file(FileInput::uploadId('file_...'));
Within a single run there is no upload de-duplication for watermark inputs — the base and the overlay are always uploaded separately, even if they are the same file.
Text label — textWatermark()
No second file: the text is the first argument.
- TypeScript
- PHP
const result = await client
.file('flyer.png')
.textWatermark('CONFIDENTIAL', { anchor: 'center', opacity: 0.5 })
.run({ maxWait: '2m' });
await result.toFile('flyer-wm.png');
$client->file('flyer.png')
->textWatermark('CONFIDENTIAL', ['anchor' => 'center', 'opacity' => 0.5])
->run(maxWait: '2m')
->toFile('flyer-wm.png');
Accepts image/jpeg, image/png, image/webp, image/tiff and image/bmp —
all stable. No video, audio or document support.
Tiling
watermark_mode: 'tiled' repeats the label across the image; tile_spacing
sets the gap in pixels.
- TypeScript
- PHP
await client
.file('flyer.png')
.textWatermark('DRAFT', {
watermark_mode: 'tiled',
tile_spacing: 120,
rotation: -30,
opacity: 0.3,
})
.run({ maxWait: '2m' });
$client->file('flyer.png')
->textWatermark('DRAFT', [
'watermark_mode' => 'tiled',
'tile_spacing' => 120,
'rotation' => -30,
'opacity' => 0.3,
])
->run(maxWait: '2m');
Options
| Option | Type | Default | Notes |
|---|---|---|---|
font_size | number | 48 | 8–512 pixels. |
color | string | '#FFFFFF80' | Hex RGB/RGBA. |
font_family | 'liberation_sans' | 'liberation_sans' | Bundled font. |
rotation | number | -45 | Degrees, -360–360. |
watermark_mode | 'single' | 'tiled' | 'single' | Rendering mode. |
tile_spacing | number | font_size | 0–1000 pixels between tiled labels. |
anchor | anchor | bottom_right | Same 9-grid as above. Ignored when tiled. |
margin_x / margin_y | string | '0px' | Offset from the anchor. Ignored when tiled. |
opacity | number | 1 | 0–1. |
The same anchor: 'center' margin rule applies in single mode.
Passing text in the options bag is rejected — the first argument owns it.
One operation per job
Both watermark operations must be the only operation in their job — and the
SDK handles that for you. Chaining .compress() / .convert() / .thumbnail()
after .watermark() is supported: the SDK lowers the post-watermark steps into a
separate downstream job that consumes the watermark's output, rather than
packing both operations into one job. You do not need two runs.
See also
- Image output — quality, target size and resize via
output(). - Thumbnail — a separate preview artifact, rather than a stamp.