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.go — normalizeMedia, runs in processMedia
(handler_process.go) before probing, regardless of profile/passthrough.
Uses ffmpeg/ffprobe.
- Trims to the shorter of video/audio stream duration if they disagree by >0.3s.
- Remux (
-c:v copy, fresh container,+faststart) — defragments multi-mdatsources some client demuxers (LG webOS) choke on. - Two-pass loudnorm, EBU R128, default -23 LUFS (
LOUDNORM_TARGET_LUFSenv).
Overwrites storageKey in place. Best-effort — logs and continues with
the original file on failure.
Video: Transcode (CMAF ladder)
handler_transcode.go — transcodeMedia/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.go — optimizeImage/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>.webp → Media.OptimizedKey.
Document: Rasterize
handler_document.go — rasterizeDocument/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.
defaultSlideDurationSec → MediaSlide.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.go — seedMediaProfile 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 |