199 lines
5.5 KiB
Plaintext
199 lines
5.5 KiB
Plaintext
-- allium: 1
|
|
-- bDS Media Lifecycle
|
|
-- Scope: core (Wave 1)
|
|
-- Distilled from: src/main/engine/MediaEngine.ts, schema.ts
|
|
|
|
use "./project.allium" as project
|
|
|
|
surface MediaControlSurface {
|
|
facing _: MediaOperator
|
|
|
|
provides:
|
|
ImportMediaRequested(project, source_file)
|
|
UpdateMediaRequested(media, changes)
|
|
DeleteMediaRequested(media)
|
|
UpsertMediaTranslationRequested(media, language, title, alt, caption)
|
|
RebuildMediaFromFilesRequested(project)
|
|
}
|
|
|
|
value ThumbnailSet {
|
|
small: String -- 150px width (binary path)
|
|
medium: String -- 400px width (binary path)
|
|
large: String -- 800px width (binary path)
|
|
ai: String -- 448x448 JPEG for vision models (binary path)
|
|
}
|
|
|
|
value SidecarFile {
|
|
-- {media_file}.meta (YAML-like key-value format)
|
|
-- Fields: title, alt, caption, author, tags, language, linkedPostIds
|
|
-- Translations: {media_file}.{lang}.meta
|
|
path: String
|
|
}
|
|
|
|
surface SidecarFileSurface {
|
|
context sidecar: SidecarFile
|
|
|
|
exposes:
|
|
sidecar.path
|
|
}
|
|
|
|
entity Media {
|
|
project: project/Project
|
|
filename: String
|
|
original_name: String
|
|
mime_type: String
|
|
size: Integer
|
|
width: Integer?
|
|
height: Integer?
|
|
title: String?
|
|
alt: String?
|
|
caption: String?
|
|
author: String?
|
|
language: String?
|
|
file_path: String
|
|
sidecar_path: String
|
|
checksum: String?
|
|
tags: List<String>
|
|
created_at: Timestamp
|
|
updated_at: Timestamp
|
|
|
|
-- Relationships
|
|
translations: MediaTranslation with media = this
|
|
linked_posts: PostMediaLink with media_id = this.id
|
|
|
|
-- Derived
|
|
available_languages: translations -> language
|
|
thumbnails: ThumbnailSet
|
|
}
|
|
|
|
surface MediaSurface {
|
|
context media: Media
|
|
|
|
exposes:
|
|
media.project
|
|
media.filename
|
|
media.original_name
|
|
media.mime_type
|
|
media.size
|
|
media.width when media.width != null
|
|
media.height when media.height != null
|
|
media.title when media.title != null
|
|
media.alt when media.alt != null
|
|
media.caption when media.caption != null
|
|
media.author when media.author != null
|
|
media.language when media.language != null
|
|
media.file_path
|
|
media.sidecar_path
|
|
media.checksum when media.checksum != null
|
|
media.tags
|
|
media.created_at
|
|
media.updated_at
|
|
media.translations.count
|
|
media.linked_posts.count
|
|
media.available_languages
|
|
media.thumbnails.small
|
|
media.thumbnails.medium
|
|
media.thumbnails.large
|
|
media.thumbnails.ai
|
|
}
|
|
|
|
entity MediaTranslation {
|
|
media: Media
|
|
language: String
|
|
title: String?
|
|
alt: String?
|
|
caption: String?
|
|
}
|
|
|
|
invariant UniqueMediaTranslation {
|
|
for a in MediaTranslations:
|
|
for b in MediaTranslations:
|
|
(a != b and a.media = b.media) implies a.language != b.language
|
|
}
|
|
|
|
invariant DateBasedMediaLayout {
|
|
for m in Media:
|
|
m.file_path = format("media/{yyyy}/{mm}/{uuid}.{ext}",
|
|
yyyy: m.created_at.year,
|
|
mm: m.created_at.month_padded,
|
|
uuid: stem(m.filename),
|
|
ext: extension(m.filename))
|
|
}
|
|
|
|
rule ImportMedia {
|
|
when: ImportMediaRequested(project, source_file)
|
|
let uuid_name = generate_uuid() + extension(source_file)
|
|
let dest = format("media/{yyyy}/{mm}/{uuid_name}",
|
|
yyyy: now.year, mm: now.month_padded)
|
|
ensures: Media.created(
|
|
project: project,
|
|
filename: uuid_name,
|
|
original_name: source_file.name,
|
|
mime_type: detect_mime(source_file),
|
|
size: source_file.size,
|
|
width: detect_width(source_file),
|
|
height: detect_height(source_file),
|
|
file_path: dest,
|
|
tags: {}
|
|
)
|
|
ensures: FileCopied(source_file, dest)
|
|
ensures: SidecarWritten(media)
|
|
ensures: ThumbnailsGenerated(media)
|
|
ensures: SearchIndexUpdated(media)
|
|
}
|
|
|
|
rule UpdateMedia {
|
|
when: UpdateMediaRequested(media, changes)
|
|
ensures: MediaFieldsUpdated(media, changes)
|
|
ensures: media.updated_at = now
|
|
ensures: SidecarWritten(media)
|
|
-- Metadata changes flush to .meta sidecar
|
|
ensures: SearchIndexUpdated(media)
|
|
}
|
|
|
|
rule DeleteMedia {
|
|
when: DeleteMediaRequested(media)
|
|
ensures: not exists media
|
|
ensures: MediaFileDeleted(media)
|
|
ensures: SidecarDeleted(media)
|
|
ensures: ThumbnailsDeleted(media)
|
|
ensures:
|
|
for t in media.translations:
|
|
not exists t
|
|
ensures: SearchIndexUpdated(media)
|
|
}
|
|
|
|
rule UpsertMediaTranslation {
|
|
when: UpsertMediaTranslationRequested(media, language, title, alt, caption)
|
|
ensures: MediaTranslation.created(
|
|
media: media,
|
|
language: language,
|
|
title: title,
|
|
alt: alt,
|
|
caption: caption
|
|
)
|
|
ensures: TranslationSidecarWritten(media, language)
|
|
-- Writes {file}.{lang}.meta
|
|
}
|
|
|
|
rule RebuildMediaFromFiles {
|
|
when: RebuildMediaFromFilesRequested(project)
|
|
-- Scans media directory for .meta sidecars, reimports to DB
|
|
for sidecar in scan_directory(project.effective_data_dir + "/media", "*.meta"):
|
|
let parsed = parse_sidecar(sidecar)
|
|
ensures: Media.created(parsed)
|
|
-- or updated if already exists
|
|
@guidance
|
|
-- This is the filesystem-to-DB reconciliation path
|
|
-- Used after git pull or manual file changes
|
|
}
|
|
|
|
invariant SidecarRoundtrip {
|
|
-- Sidecar files faithfully represent DB metadata
|
|
for m in Media:
|
|
parse_sidecar(m.sidecar_path).title = m.title
|
|
parse_sidecar(m.sidecar_path).alt = m.alt
|
|
parse_sidecar(m.sidecar_path).caption = m.caption
|
|
parse_sidecar(m.sidecar_path).tags = m.tags
|
|
}
|