Back to flin
flin

File Preview Generation

How FLIN generates preview thumbnails for uploaded files automatically.

Thales & Claude | March 25, 2026 9 min flin
flinfile-previewthumbnailsimage-processingstorage

Every application that accepts image uploads eventually needs thumbnails. Profile pictures need 40-pixel avatars in navigation bars and 200-pixel versions on profile pages. Product catalogs need grid thumbnails and detail views. Document management systems need visual previews so users can identify files without opening them.

Most frameworks leave thumbnail generation to the developer. You install ImageMagick or Sharp, write a processing pipeline, decide on sizes and formats, store the output somewhere, track the association between originals and thumbnails, and handle errors when corrupt images crash your processing pipeline. It is 200-500 lines of code that every image-handling application reimplements.

FLIN generates previews automatically. Upload an image, and three preview sizes appear alongside the original in content-addressable storage. Access them through typed properties on the file object. No configuration, no processing pipeline, no additional storage management. Sessions 235 and 236 implemented the complete system in two sessions.

Preview Architecture

Previews are stored alongside the original file in the same content-addressable directory:

.flindb/blobs/{shard}/{hash}/
    data.jpg              # Original file (e.g., 4000x3000, 2.5 MB)
    preview_100.webp      # Thumbnail (100px wide, ~3 KB)
    preview_300.webp      # Medium preview (300px wide, ~15 KB)
    preview_800.webp      # Large preview (800px wide, ~60 KB)

Three sizes cover the common use cases:

SizeNameUse Case
100pxThumbnailAvatars, grid icons, navigation
300pxMedium (default)Cards, list items, chat previews
800pxLargeDetail views, lightbox, full-width cards

The height is calculated proportionally to maintain the original aspect ratio. A 4000x3000 image produces a 100x75 thumbnail, a 300x225 medium preview, and an 800x600 large preview.

The Preview Generator

The preview generation module handles format detection, resizing, and output encoding:

rustpub struct PreviewConfig {
    pub enabled: bool,                 // Default: true
    pub sizes: Vec<u32>,               // Default: [100, 300, 800]
    pub format: PreviewFormat,         // Default: WebP
    pub quality: u8,                   // Default: 80 (1-100)
}

pub enum PreviewFormat {
    WebP,
    Jpeg,
}

pub struct PreviewGenerator {
    config: PreviewConfig,
}

pub struct PreviewData {
    pub size: u32,                     // Target width
    pub data: Vec<u8>,                 // Encoded image bytes
    pub width: u32,                    // Actual width
    pub height: u32,                   // Actual height
    pub format: PreviewFormat,
}

WebP is the default output format because it produces smaller files than JPEG at equivalent quality and supports transparency (unlike JPEG). A 300px preview in WebP is typically 30-50% smaller than the same preview in JPEG.

Supported Input Formats

The generator accepts four common image formats:

rustimpl PreviewGenerator {
    pub fn supports_preview(content_type: &str) -> bool {
        matches!(content_type,
            "image/jpeg" | "image/jpg" |
            "image/png" |
            "image/webp" |
            "image/gif"
        )
    }

    pub fn generate(
        &self,
        data: &[u8],
        target_size: u32,
    ) -> Result<PreviewData, PreviewError> {
        let img = image::load_from_memory(data)
            .map_err(|e| PreviewError::DecodeFailed(e.to_string()))?;

        let (orig_width, orig_height) = img.dimensions();

        // Skip if image is already smaller than target
        if orig_width <= target_size {
            return Ok(PreviewData {
                size: target_size,
                data: data.to_vec(),
                width: orig_width,
                height: orig_height,
                format: self.config.format.clone(),
            });
        }

        // Calculate proportional height
        let ratio = target_size as f64 / orig_width as f64;
        let target_height = (orig_height as f64 * ratio) as u32;

        // Resize using Lanczos3 for quality
        let resized = img.resize(target_size, target_height, image::imageops::Lanczos3);

        // Encode to output format
        let encoded = self.encode(&resized)?;

        Ok(PreviewData {
            size: target_size,
            data: encoded,
            width: target_size,
            height: target_height,
            format: self.config.format.clone(),
        })
    }
}

The Lanczos3 resampling filter produces the highest quality downscaled images. It is slower than bilinear or nearest-neighbor resampling but the difference is milliseconds for preview-sized images.

If the original image is smaller than the target size, no resizing is performed. A 50x50 avatar is returned as-is when a 100px preview is requested. This prevents upscaling artifacts and avoids wasting CPU time enlarging images.

HTTP Upload Integration

Session 236 wired preview generation into the upload pipeline. When a file is uploaded through the HTTP server, previews are generated automatically for supported image formats:

rust// In VM::native_save_file()
// After the original file is saved:

if self.preview_config.enabled {
    if let Some(content_type) = file_map.get("content_type") {
        if PreviewGenerator::supports_preview(content_type) {
            let raw_bytes = get_raw_bytes(&file_map)?;
            self.generate_and_store_previews(
                &hash,
                &extension,
                &raw_bytes,
                content_type,
                &base_dir,
            );
        }
    }
}

The generate_and_store_previews method iterates over configured sizes and stores each preview:

rustfn generate_and_store_previews(
    &self,
    hash: &str,
    extension: &str,
    data: &[u8],
    content_type: &str,
    base_dir: &str,
) {
    let generator = PreviewGenerator::new(self.preview_config.clone());
    let backend = LocalBackend::new(base_dir, "");

    for size in &self.preview_config.sizes {
        // Skip if preview already exists (deduplication)
        if backend.preview_exists(hash, *size) {
            continue;
        }

        match generator.generate(data, *size) {
            Ok(preview) => {
                if let Err(e) = backend.put_preview(hash, *size, &preview.data) {
                    warn!("Preview generation warning: {}", e);
                }
            }
            Err(e) => {
                warn!("Preview generation warning for size {}: {}", size, e);
            }
        }
    }
}

Two critical design decisions are embedded in this code:

Non-blocking errors. Preview generation failures are logged as warnings, not errors. If a corrupt image fails to generate a 100px thumbnail, the upload still succeeds. The original file is stored; only the previews are missing. This is the right trade-off for a feature that enhances the developer experience but should never block core functionality.

Deduplication. Before generating each preview, the method checks if it already exists. If the same image is uploaded twice (same SHA-256 hash), the previews from the first upload are reused. Combined with content-addressable storage for the originals, this means duplicate uploads are nearly free.

Accessing Previews in FLIN

Previews are exposed as typed properties on file fields:

flinentity Photo {
    title: text
    image: file
}

// In templates
<img src={photo.image.thumbnail} alt={photo.title} />
<img src={photo.image.preview} alt={photo.title} />
<img src={photo.image.preview_large} alt={photo.title} />

// Conditional rendering
{if photo.image.has_preview}
    <img src={photo.image.preview} alt={photo.title} />
{else}
    <div class="placeholder">No preview available</div>
{/if}

The property names map to preview sizes:

PropertyAliasSize
thumbnailpreview_small100px
previewpreview_medium300px
preview_large--800px
has_preview--Boolean

These properties are added to the type checker for both FlinType::File and FlinType::Semantic(File). The type checker validates property access at compile time, so photo.image.thumbnail is type-checked and photo.image.nonexistent_property produces a compile error.

Garbage Collection Integration

When a blob is garbage collected (reference count drops to zero and the grace period expires), its previews must be deleted too. Session 235 updated the GC sweep to handle this:

rustpub fn delete_blob_with_previews(
    backend: &dyn StorageBackend,
    hash: &str,
    extension: &str,
) -> Result<(), StorageError> {
    // Delete the original
    let path = format!("{}/{}/data.{}", &hash[..2], hash, extension);
    backend.delete(&path)?;

    // Delete all previews in the hash directory
    // For LocalBackend: delete the entire directory
    let dir_path = format!("{}/{}", &hash[..2], hash);
    if let Err(e) = std::fs::remove_dir_all(&dir_path) {
        // Directory might already be gone, that is fine
        debug!("Preview cleanup note: {}", e);
    }

    Ok(())
}

Deleting the entire hash directory is simpler and more reliable than deleting individual preview files. If new preview sizes are added in the future, the GC does not need to be updated -- it already deletes everything in the directory.

What Is Deferred

Several preview features were deliberately deferred to V2:

PDF first-page preview. Rendering the first page of a PDF as an image requires a PDF rendering engine like Pdfium. The pdfium-render crate could provide this, but it adds a significant dependency for a niche feature.

Video frame extraction. Generating a thumbnail from the first frame of a video requires FFmpeg bindings. The ffmpeg-next crate exists but is complex to build and cross-compile.

Cloud preview storage. Currently, previews are generated and stored only on the local backend. R2 and GCS backends would need their own preview storage and retrieval methods.

Async background generation. Currently, previews are generated synchronously during the upload request. For very large images, this adds latency. Background generation with a task queue would decouple upload speed from preview quality.

These features represent diminishing returns. Image previews cover the majority of use cases. PDF and video previews are valuable but affect fewer applications. Cloud storage and async generation are optimizations that matter only at significant scale.

Test Coverage

The preview system includes 39 tests across three modules:

ModuleTestsCoverage
preview.rs18Config, format, MIME detection, generation, helpers
local.rs10Put, get, exists, delete, path traversal, multiple sizes
gc.rs3Delete with previews, without previews, nonexistent
vm.rs6Upload integration, skip non-images, dedup, disabled config
Total39

The VM integration tests are the most important. They simulate a complete upload, verify that three previews are created, verify that non-image uploads produce no previews, and verify that disabled configuration prevents preview generation.

The Complete File Storage Arc

This article concludes Arc 12 -- FLIN's File Storage System. Ten articles covering:

  1. Four storage backends -- local, S3, R2, GCS -- behind a unified trait.
  2. The StorageBackend trait -- Rust's type system enforcing thread safety and interchangeability.
  3. Cloud backends -- S3 compatibility for R2, OAuth2 and V4 signing for GCS.
  4. Download grants -- time-limited, use-limited, password-protected file access.
  5. Text chunking -- recursive character and sentence-boundary strategies for RAG.
  6. Chunk-embedding integration -- the pipeline from raw bytes to searchable vectors.
  7. Format parsers -- CSV, XLSX, RTF, and XML extraction with XPath support.
  8. Semantic auto-conversion -- one keyword activates an entire embedding pipeline.
  9. Compression and GC -- Zstd for storage efficiency, reference counting for cleanup.
  10. Preview generation -- automatic thumbnails for uploaded images.

Across Sessions 212 to 236, the file management system grew from a basic upload handler to a comprehensive document intelligence platform. The test count went from 2,929 to 3,533 -- 604 new tests covering every feature, every edge case, and every error path.

The philosophy throughout was the same as the rest of FLIN: take something that typically requires assembling half a dozen services and libraries, and build it into the language runtime where it can be type-checked, optimized, and used with minimal ceremony. A FLIN developer who writes file: file gets storage, deduplication, compression, previews, and garbage collection. A developer who writes content: semantic text gets extraction, chunking, embedding, and search. The complexity is real; it just lives in the runtime instead of in the application.


This is Part 135 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [134] Zstd Compression and Blob Garbage Collection - [135] File Preview Generation (you are here) - Next arc: FLIN Standard Library and Ecosystem

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles