Skip to content

Transcoding

One shared TranscodingProfile table, category-discriminated (video/image/document), covering three independent pipelines plus a passthrough convention common to all of them. CRUD lives in handler_transcoding_profiles.go; the frontend side is documented in the Media Transcoding Presets capability app.


Data Model

erDiagram
    TranscodingProfile {
        int id
        string name
        string code
        string category
        string container
        bool isDefault
        int currentVersionId
    }
    TranscodingProfileVersion {
        int id
        int transcodingProfileId
        int version
        json renditions
        json settings
        int segmentSecs
        string changeNote
    }
    MediaSlide {
        int id
        int mediaId
        int order
        string imageKey
        int durationSec
    }
    TranscodingProfile ||--o{ TranscodingProfileVersion : "history of"
    Media }o--|| TranscodingProfile : "processed with"
    Media ||--o{ MediaSlide : "rasterized into (document)"

models.go: Renditions (array, video ladder) and Settings (object, image/document config) are separate JSONB columns on TranscodingProfileVersion, not one column with two shapes — a version only ever populates the column its own category uses. Code on TranscodingProfile is nullable+unique (ad-hoc UI-created profiles don't set one, so they never collide with each other or seeded rows). Container exists only for video — read by nothing for image/document, and per the main index's To-do, not actually read by anything in the video pipeline either currently.

Editing (UpdateTranscodingProfile) always inserts a new TranscodingProfileVersion and repoints CurrentVersionID — it never mutates a version in place. Media rows pin to whichever version they were actually processed with (TranscodingProfileVersionID), so a new version never triggers a bulk re-encode. The one deliberate exception is seed-boot convergence (below), which updates the current version's content in place rather than versioning — seed data isn't a real edit.


Passthrough

Every category has an explicit, seeded, selectable profile that skips its pipeline and marks the asset done as-is:

Category Passthrough signal Seeded profile Checked in
video Renditions empty VIDEO-PASSTHROUGH handler_transcode.go
image Settings.format empty IMAGE-PASSTHROUGH handler_image.go (optimizeImage)
document Settings.dpi/colorScheme/defaultSlideDurationSec all unset DOCUMENT-PASSTHROUGH handler_document.go (rasterizeDocument)

Checked on the parsed settings struct, not raw JSON length — an explicit {} (a Passthrough checkbox that submits an empty object) is treated the same as an omitted key.


Video: Normalize (unconditional, every video upload)

handler_normalize.gonormalizeMedia, runs in processMedia (handler_process.go) before probing, regardless of profile/passthrough. Uses ffmpeg/ffprobe.

  1. Trims to the shorter of video/audio stream duration if they disagree by >0.3s.
  2. Remux (-c:v copy, fresh container, +faststart) — defragments multi-mdat sources some client demuxers (LG webOS) choke on.
  3. Two-pass loudnorm, EBU R128, default -23 LUFS (LOUDNORM_TARGET_LUFS env).

Overwrites storageKey in place. Best-effort — logs and continues with the original file on failure.

Video: Transcode (CMAF ladder)

handler_transcode.gotranscodeMedia/runFFmpegCMAF/ writeMasterPlaylist. Uses ffmpeg. Gated on MediaType==video && TranscodingProfileID != nil, real (non-passthrough) Renditions. Output: per-mediaID hls/+smil/ tree (see Storage Layout).

Image: Optimize

handler_image.gooptimizeImage/runFFmpegImageOptimize. Uses ffmpeg's libwebp encoder — no separate cwebp dependency. Gated on MediaType==image && TranscodingProfileID != nil.

{ "format": "webp", "quality": 85, "maxWidth": 1920, "maxHeight": 1080 }

Resize only shrinks (force_original_aspect_ratio=decrease, never upscales). Output: <dir>/optimized/<stem>.webpMedia.OptimizedKey.

Document: Rasterize

handler_document.gorasterizeDocument/runGhostscriptRasterize. Uses ghostscript (gs) for the actual rasterization, and, for PPT/PPTX/ODP sources, LibreOffice headless (soffice, via convertOfficeToPDF in handler_process.go) to get to PDF first. Gated on MediaType==document && TranscodingProfileID != nil; fails clearly (not silently) if the source mime type isn't PDF/office.

{ "dpi": 150, "colorScheme": "rgb", "defaultSlideDurationSec": 8 }

Renders every page (not just page 1, unlike the thumbnail path) to <dir>/slides/<stem>/page-N.jpg, one MediaSlide row per page (GET /media/:id/slides — always returns [], never 404, for unrasterized/passthrough documents). colorScheme: "grayscale" switches the ghostscript device to jpeggray.

defaultSlideDurationSecMediaSlide.DurationSec is a default only — never fed into any encode command. Tellyboard's own per-instance schedule duration is expected to override it at consumption time.


Thumbnail Generation

Independent of the pipelines above — always a single preview frame/page, generated in generateThumbnail (handler_process.go) right after the filer move, regardless of category or profile.

Type Tool Output
video ffmpeg — frame at 1s .jpg
image ffmpeg — scale 480px wide .jpg
pdf ghostscript — page 1, 72dpi .jpg
office (ppt/pptx/odp) LibreOffice → PDF (convertOfficeToPDF), then the ghostscript path above .jpg
audio / other skipped

A rasterized document therefore ends up with both a thumbnailKey (page 1, small, for list views) and a full MediaSlide set (all pages, for actual playback/scheduling) — two different code paths producing different things.


Seeded Profiles

pkg/seed/mediatranscoding_presets.goseedMediaProfile upserts by Code on every server boot, converging the current version's content to whatever the file defines (not inserting a new version each restart).

Code Category Values
VIDEO-STD-3RUNG video default; 1080/720/480p, 5000/2800/1200 kbps
VIDEO-LITE-2RUNG video 480/360p, 700/400 kbps
VIDEO-PASSTHROUGH video empty renditions
IMAGE-SIGNAGE-STANDARD image default; webp, q85, 1920×1080
IMAGE-SIGNAGE-SOCIAL image webp, q85, 1080×1080
IMAGE-SIGNAGE-ADS-BANNER image webp, q80, 1920×400
IMAGE-PASSTHROUGH image empty settings
DOCUMENT-SIGNAGE-DEFAULT document default; 150dpi, rgb, 8s/slide
DOCUMENT-PASSTHROUGH document empty settings