15-minute quickstart
This exercise turns the classic “read → process → export” workflow into one comparable task across four interfaces: shrink input.jpg to fit within 640 × 640, never enlarge it, and write output.webp at quality 82.
1. Prepare an input
Section titled “1. Prepare an input”To run the example, name your own JPEG input.jpg; different dimensions and image content will produce different output measurements.


The reference output was generated with govips v2.19.0 and libvips 8.17.3.
2. Choose an interface and run it
Section titled “2. Choose an interface and run it”Run command
go run . input.jpg output/Complete source
package main
import (
"fmt"
"os"
"github.com/davidbyttow/govips/v2/vips"
"github.com/webp-sh/vips-handbook/examples/internal/recipeutil"
)
func main() {
if err := run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) != 3 {
return fmt.Errorf("usage: quickstart-thumbnail <input.jpg> <output-directory>")
}
inputPath, outputDir := args[1], args[2]
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return err
}
if err := vips.Startup(nil); err != nil {
return fmt.Errorf("start libvips: %w", err)
}
defer vips.Shutdown()
image, err := vips.NewThumbnailFromFile(inputPath, 640, 640, vips.InterestingNone)
if err != nil {
return fmt.Errorf("create thumbnail: %w", err)
}
defer image.Close()
output, err := recipeutil.ExportWebp(image, outputDir, "output.webp", 82, map[string]any{
"width": 640, "height": 640, "crop": "none", "withoutEnlargement": true,
})
if err != nil {
return err
}
if output.Width > 640 || output.Height > 640 {
return fmt.Errorf("unexpected thumbnail dimensions: %dx%d", output.Width, output.Height)
}
if err := recipeutil.WriteResult(outputDir, "quickstart-thumbnail", inputPath, []recipeutil.Output{output}); err != nil {
return err
}
fmt.Printf("quickstart-thumbnail: %dx%d, %d bytes\n", output.Width, output.Height, output.Size)
return nil
}Run command
node index.mjs input.jpg output/Complete source
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
const [inputPath, outputDir] = process.argv.slice(2);
if (!inputPath || !outputDir) {
console.error('Usage: node index.mjs <input.jpg> <output-directory>');
process.exit(2);
}
try {
await mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'output.webp');
const writeResult = await sharp(inputPath)
.resize(640, 640, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 })
.toFile(outputPath);
if (writeResult.width > 640 || writeResult.height > 640) {
throw new Error(`Unexpected thumbnail dimensions: ${writeResult.width}x${writeResult.height}`);
}
await writeFile(
path.join(outputDir, 'result.json'),
`${JSON.stringify({
recipeId: 'quickstart-thumbnail',
generator: { sharp: sharp.versions.sharp, libvips: sharp.versions.vips },
input: path.basename(inputPath),
outputs: [{
path: 'output.webp',
format: writeResult.format,
width: writeResult.width,
height: writeResult.height,
size: writeResult.size,
}],
}, null, 2)}\n`,
);
console.log(`quickstart-thumbnail: ${writeResult.width}x${writeResult.height}, ${writeResult.size} bytes`);
} catch (error) {
console.error(error);
process.exit(1);
}Run command
python3 main.py input.jpg output/Complete source
import sys
import json
import os
import pyvips
input_path, output_dir = sys.argv[1], sys.argv[2]
if not input_path or not output_dir:
print("Usage: python3 main.py <input.jpg> <output-directory>", file=sys.stderr)
raise SystemExit(2)
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "output.webp")
try:
image = pyvips.Image.thumbnail(input_path, 640, height=640, size="down")
image.write_to_file(output_path, Q=82)
if image.width > 640 or image.height > 640:
raise ValueError(f"Unexpected thumbnail dimensions: {image.width}x{image.height}")
result_path = os.path.join(output_dir, "result.json")
with open(result_path, "w") as f:
json.dump({
"recipeId": "quickstart-thumbnail",
"generator": {
"pyvips": pyvips.__version__,
"libvips": f"{pyvips.version(0)}.{pyvips.version(1)}.{pyvips.version(2)}",
},
"input": os.path.basename(input_path),
"outputs": [{
"path": "output.webp",
"format": "webp",
"width": image.width,
"height": image.height,
"bands": image.bands,
"hasAlpha": image.hasalpha(),
"size": os.path.getsize(output_path),
}],
}, f, indent=2)
f.write("\n")
print(f"quickstart-thumbnail: {image.width}x{image.height}, {os.path.getsize(output_path)} bytes")
except pyvips.Error as error:
print(error, file=sys.stderr)
raise SystemExit(1)Run command
bash run.sh input.jpg output/Complete source
#!/usr/bin/env bash
set -euo pipefail
INPUT="$1"
OUTPUT_DIR="$2"
if [ $# -ne 2 ]; then
echo "Usage: bash run.sh <input.jpg> <output-directory>" >&2
exit 2
fi
mkdir -p "$OUTPUT_DIR"
OUTPUT_WEBP="$OUTPUT_DIR/output.webp"
vips thumbnail "$INPUT" "$OUTPUT_WEBP[Q=82]" 640 --height 640 --size down
IDATA="$(vipsheader --all "$OUTPUT_WEBP")"
WIDTH="$(echo "$IDATA" | grep '^width:' | awk '{print $2}')"
HEIGHT="$(echo "$IDATA" | grep '^height:' | awk '{print $2}')"
BANDS="$(echo "$IDATA" | grep '^bands:' | awk '{print $2}')"
FILESIZE="$(stat -c%s "$OUTPUT_WEBP")"
LIBVIPS_VER="$(vips --version | sed 's/vips-//')"
if [ "$WIDTH" -gt 640 ] || [ "$HEIGHT" -gt 640 ]; then
echo "Unexpected thumbnail dimensions: ${WIDTH}x${HEIGHT}" >&2
exit 1
fi
cat > "$OUTPUT_DIR/result.json" << EOF
{
"recipeId": "quickstart-thumbnail",
"generator": {
"vipsCli": "${LIBVIPS_VER}",
"libvips": "${LIBVIPS_VER}"
},
"input": "$(basename "$INPUT")",
"outputs": [{
"path": "output.webp",
"format": "webp",
"width": ${WIDTH},
"height": ${HEIGHT},
"bands": ${BANDS},
"hasAlpha": false,
"size": ${FILESIZE}
}]
}
EOF
echo "quickstart-thumbnail: ${WIDTH}x${HEIGHT}, ${FILESIZE} bytes"3. Inspect the result
Section titled “3. Inspect the result”At minimum, confirm that output.webp exists and can be opened again; neither dimension is greater than 640; an input smaller than the target was not enlarged; and the output is WebP data, not a renamed file.
- Format: WEBP
- Dimensions: 640 × 427
- Bands: 3
- Alpha: No
- File size: 36,172 bytes
- Generated with: govips v2.19.0 / libvips 8.17.3
The four snippets target the same product behavior, but they are not line-for-line equivalents. govips and pyvips call thumbnail operations directly, sharp expresses the boundary with resize({ fit: 'inside' }), and the CLI passes saver options through the filename.
Where the four results differ
Section titled “Where the four results differ”The same 1938 × 1292 JPEG was fit inside 640 pixels without enlargement, as WebP quality 82. Results from the 2026-10-12 run:
| Interface | libvips | Output size | Bytes |
|---|---|---|---|
| govips v2.19.0 | 8.17.3 | 640 × 427 | 36,172 |
| sharp 0.35.5 | 8.18.7 | 640 × 427 | 35,924 |
| pyvips 3.2.0 | 8.17.3 | 640 × 427 | 39,228 |
| vips CLI 8.17.3 | 8.17.3 | 640 × 427 | 39,228 |
Dimensions, format, and the no-enlargement rule line up. Byte size does not. sharp’s libvips is one minor version ahead of the other three. pyvips and the CLI share 8.17.3 and the same byte size, which still differs slightly from the govips thumbnail path.
Continue to build a complete image pipeline and make input checks, orientation normalization, encoding, and result verification explicit stages.