-- allium: 1 -- bDS Media Processing Specification -- Scope: core (Wave 1 — media import and processing) -- Distilled from: ../bDS/src/main/engine/MediaEngine.ts, -- mediaProcessing.ts, thumbnail generation logic -- -- This document specifies the exact media processing behavior: -- thumbnail generation, format conversion, EXIF handling, and file organization. use "./media.allium" as media use "./search.allium" as search surface MediaProcessingControlSurface { facing _: MediaProcessingOperator provides: ImportMediaRequested(source_path, project) TagMediaRequested(media, tags) DeleteMediaRequested(media) ValidateMediaRequested(project) } surface MediaProcessingRuntimeSurface { facing _: MediaProcessingRuntime provides: MediaImported(media) } -- ============================================================================ -- MEDIA FILE ORGANIZATION -- ============================================================================ value MediaFileLayout { -- Binary assets stored in: media/{YYYY}/{MM}/{uuid}.{ext} -- Sidecar metadata in: {binary_path}.meta -- Thumbnails in: thumbnails/{id[0:2]}/{id}-{size}.webp -- (ai thumbnail is JPEG: thumbnails/{id[0:2]}/{id}-ai.jpg) binary_path: String -- media/{YYYY}/{MM}/{uuid}.{ext} sidecar_path: String -- {binary_path}.meta thumbnail_small: String -- thumbnails/{prefix}/{id}-small.webp thumbnail_medium: String -- thumbnails/{prefix}/{id}-medium.webp thumbnail_large: String -- thumbnails/{prefix}/{id}-large.webp thumbnail_ai: String -- thumbnails/{prefix}/{id}-ai.jpg } surface MediaFileLayoutSurface { context layout: MediaFileLayout exposes: layout.binary_path layout.sidecar_path layout.thumbnail_small layout.thumbnail_medium layout.thumbnail_large layout.thumbnail_ai } invariant MediaFileNaming { -- Original filename is preserved in original_name field -- Stored filename uses UUID v4: {uuid}.{ext} -- Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg for m in Media: m.filename = format("{uuid}.{ext}", uuid: generate_uuid_v4(), ext: file_extension(m.original_name)) } invariant ThumbnailPathBucketing { -- Thumbnails are bucketed by first 2 chars of media ID -- This avoids filesystem slowdowns from too many files in one directory for m in Media: let prefix = substring(m.id, 0, 2) m.thumbnails.small = format("thumbnails/{prefix}/{id}-small.webp", prefix: prefix, id: m.id) m.thumbnails.medium = format("thumbnails/{prefix}/{id}-medium.webp", prefix: prefix, id: m.id) m.thumbnails.large = format("thumbnails/{prefix}/{id}-large.webp", prefix: prefix, id: m.id) m.thumbnails.ai = format("thumbnails/{prefix}/{id}-ai.jpg", prefix: prefix, id: m.id) } -- ============================================================================ -- THUMBNAIL GENERATION -- ============================================================================ config { -- Four thumbnail sizes generated per image thumbnail_small_width: Integer = 150 thumbnail_medium_width: Integer = 400 thumbnail_large_width: Integer = 800 thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG thumbnail_format: String = "webp" -- All sizes except AI (encoder default quality) thumbnail_ai_format: String = "jpeg" -- AI thumbnail only } rule GenerateThumbnails { when: MediaImported(media) requires: is_image(media.mime_type) -- Generate all four thumbnail sizes ensures: ThumbnailGenerated( source: media.file_path, destination: media.thumbnails.small, width: config.thumbnail_small_width, format: config.thumbnail_format ) ensures: ThumbnailGenerated( source: media.file_path, destination: media.thumbnails.medium, width: config.thumbnail_medium_width, format: config.thumbnail_format ) ensures: ThumbnailGenerated( source: media.file_path, destination: media.thumbnails.large, width: config.thumbnail_large_width, format: config.thumbnail_format ) ensures: ThumbnailGenerated( source: media.file_path, destination: media.thumbnails.ai, size: config.thumbnail_ai_size, format: config.thumbnail_ai_format ) } -- Thumbnail generation algorithm value ThumbnailGeneration { -- 1. Load source image -- 2. Apply EXIF orientation correction (rotation, flip) so thumbnails display correctly -- 3. Resize: small/medium/large preserve aspect ratio (width-constrained) -- AI thumbnail is a 448x448 center crop (letterboxed on black background) -- 4. Encode as WebP (encoder default quality) for small/medium/large -- Encode as JPEG for AI thumbnail -- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext} -- -- No _source copy is made. Thumbnails regenerated from the original binary. } surface ThumbnailGenerationSurface { context _: ThumbnailGeneration } invariant ThumbnailExifHandling { -- EXIF orientation IS applied during thumbnail generation so that -- thumbnails always appear right-side-up regardless of camera metadata. -- Width/height stored in DB are the raw header values (pre-rotation). } -- ============================================================================ -- IMAGE PROCESSING RULES -- ============================================================================ value ImageProcessing { -- Supported input formats: input_formats: Set = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/tiff", "image/bmp", "image/heic", "image/heif" } -- Output formats: output_formats: Set = { "image/webp", -- Primary output for thumbnails "image/jpeg" -- AI thumbnail only } -- Processing rules: -- 1. All thumbnails (except AI) are encoded as WebP (encoder default quality) -- 2. AI thumbnail is encoded as JPEG (for vision model compatibility) -- 3. Original format is preserved for full-size assets (no conversion) -- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent) } surface ImageProcessingSurface { context processing: ImageProcessing exposes: processing.input_formats processing.output_formats } rule ProcessImageMetadata { when: MediaImported(media) -- Extract image metadata from raw file header ensures: media.width = extract_width_from_header(source_file) ensures: media.height = extract_height_from_header(source_file) ensures: media.mime_type = detect_mime_from_extension(source_file) ensures: media.size = file_size(source_file) } invariant MimeDetection { -- MIME type is detected from file extension, not from file content/magic bytes -- Extension mapping: .jpg/.jpeg -> image/jpeg, .png -> image/png, etc. } -- ============================================================================ -- MEDIA TRANSLATION FILES -- ============================================================================ value MediaTranslationFile { -- File path: {binary_path}.{language}.meta -- Format: YAML-like key-value sidecar (same as canonical sidecar) translation_for: String -- Canonical media ID language: String -- ISO 639-1 code title: String? alt: String? caption: String? } surface MediaTranslationFileSurface { context file: MediaTranslationFile exposes: file.translation_for file.language file.title when file.title != null file.alt when file.alt != null file.caption when file.caption != null } invariant MediaTranslationFileLayout { for t in MediaTranslations: -- Translation sidecars sit next to the binary, with language suffix t.file_path = format("{binary_path}.{lang}.meta", binary_path: t.media.file_path, lang: t.language) } -- ============================================================================ -- MEDIA IMPORT RULES -- ============================================================================ rule ImportMedia { when: ImportMediaRequested(source_path, project) -- 1. Validate file type (must be supported image) -- 2. Generate UUID v4 filename -- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext} -- 4. Write sidecar {binary_path}.meta -- 5. Generate four thumbnail sizes -- 6. Index for search (FTS5) ensures: media/Media.created( filename: generate_uuid_v4_filename(source_path), original_name: basename(source_path), mime_type: detect_mime_from_extension(source_path), size: file_size(source_path), width: extract_width_from_header(source_path), height: extract_height_from_header(source_path), file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"), sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"), checksum: sha256(source_path) ) ensures: ThumbnailsGenerated(media_id) ensures: SearchIndexUpdated(media_id) } -- ============================================================================ -- MEDIA TAGGING -- ============================================================================ invariant MediaTagsFormat { -- Media tags are stored as JSON array in sidecar file -- Tags are optional and only written if present -- Same format as post tags } rule TagMedia { when: TagMediaRequested(media, tags) ensures: media.tags = tags ensures: SidecarFileUpdated(media) ensures: SearchIndexUpdated(media) } -- ============================================================================ -- MEDIA DELETION -- ============================================================================ rule DeleteMedia { when: DeleteMediaRequested(media) -- 1. Remove from database -- 2. Delete binary file -- 3. Delete sidecar file ({binary_path}.meta) -- 4. Delete all four thumbnail files -- 5. Delete translation sidecars ({binary_path}.{lang}.meta) -- 6. Remove from search index -- 7. Remove from all post links ensures: FileDeleted(media.file_path) ensures: FileDeleted(media.sidecar_path) ensures: FileDeleted(media.thumbnails.small) ensures: FileDeleted(media.thumbnails.medium) ensures: FileDeleted(media.thumbnails.large) ensures: FileDeleted(media.thumbnails.ai) ensures: SearchIndexRemoved(media) ensures: for p in media.linked_posts: p.linked_media = p.linked_media - {media} } -- ============================================================================ -- MEDIA VALIDATION -- ============================================================================ rule ValidateMedia { when: ValidateMediaRequested(project) -- Check for: -- 1. Missing binary files -- 2. Missing sidecar files -- 3. Missing thumbnails (all 4 sizes) -- 4. Corrupted image files -- 5. Orphan media (not linked to any post) for m in project.media: if not file_exists(m.file_path): ensures: ValidationIssueReported(m, "missing_binary") if not file_exists(m.sidecar_path): ensures: ValidationIssueReported(m, "missing_sidecar") if not file_exists(m.thumbnails.small): ensures: ValidationIssueReported(m, "missing_thumbnail_small") if not file_exists(m.thumbnails.medium): ensures: ValidationIssueReported(m, "missing_thumbnail_medium") if not file_exists(m.thumbnails.large): ensures: ValidationIssueReported(m, "missing_thumbnail_large") if not file_exists(m.thumbnails.ai): ensures: ValidationIssueReported(m, "missing_thumbnail_ai") if not is_valid_image(m.file_path): ensures: ValidationIssueReported(m, "corrupted") if not exists (p in Posts where m in p.linked_media): ensures: ValidationIssueReported(m, "orphan") } -- ============================================================================ -- MEDIA SIDECAR FORMAT -- ============================================================================ invariant MediaSidecarFormat { -- Sidecar files use YAML-like key-value format (hand-built, not gray-matter) -- Path: {binary_path}.meta -- Only truthy fields are written (except required fields) -- Fields: title, alt, caption, author, tags, language, linkedPostIds -- Note: 'filename' is NOT written to sidecar (it is the binary filename itself) } -- ============================================================================ -- IMAGE OPTIMIZATION -- ============================================================================ config { -- No file size limit on import -- Original files are stored as-is (no compression, no resize) -- Only thumbnails are generated from the original strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it } -- ============================================================================ -- MEDIA SEARCH INDEXING -- ============================================================================ rule IndexMediaForSearch { when: SearchIndexUpdated(media: media/Media) -- Index fields: title, alt, caption, original_name, tags -- Plus all translation titles, alts, and captions let all_text = concat( media.title, media.alt, media.caption, media.original_name, join(media.tags, " ") ) let stemmed = stem(all_text, detect_language(all_text)) ensures: search/MediaSearchIndex.created( media: media, stemmed_content: stemmed ) }