Skip to content

Commit 517e5c7

Browse files
committed
feat: show waveform thumbnails if available
1 parent c82f6c1 commit 517e5c7

5 files changed

Lines changed: 154 additions & 4 deletions

File tree

backend/cmd/server/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func main() {
166166
cantemoToken: os.Getenv("CANTEMO_TOKEN"),
167167
})
168168
mux.Handle("/vault/image", newVaultImageHandler(cantemoClient, os.Getenv("CANTEMO_TOKEN")))
169+
mux.Handle("/vault/waveform", newVaultWaveformHandler(vaultAPI))
169170

170171
mux.Handle("/", http.HandlerFunc(serveFiles))
171172

backend/cmd/server/vault.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,30 @@ func (v VaultAPI) fetchImage(u string) ([]byte, string, error) {
288288
return resp.Body(), ct, nil
289289
}
290290

291+
// fetchWaveform returns Vidispine's pre-rendered waveform PNG for an audio
292+
// item. See https://apidoc.vidispine.com/4.17/ref/item/analyze.html — the
293+
// endpoint requires that shape analysis has run on the item.
294+
func (v VaultAPI) fetchWaveform(vxID string, width, height int, bgColor, fgColor string) ([]byte, error) {
295+
req := v.rest.R().SetQueryParams(map[string]string{
296+
"width": strconv.Itoa(width),
297+
"height": strconv.Itoa(height),
298+
})
299+
if bgColor != "" {
300+
req.SetQueryParam("bgcolor", bgColor)
301+
}
302+
if fgColor != "" {
303+
req.SetQueryParam("fgcolor", fgColor)
304+
}
305+
resp, err := req.Get("/item/" + url.PathEscape(vxID) + "/waveform/image")
306+
if err != nil {
307+
return nil, err
308+
}
309+
if resp.IsError() {
310+
return nil, fmt.Errorf("waveform fetch failed (status %d): %s", resp.StatusCode(), string(resp.Body()))
311+
}
312+
return resp.Body(), nil
313+
}
314+
291315
// absolutize resolves an API-relative URI against the Vidispine host. Vidispine
292316
// usually returns absolute URIs already, in which case this is a no-op.
293317
func (v VaultAPI) absolutize(u string) string {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"regexp"
6+
"strconv"
7+
8+
"github.com/bcc-code/mediabank-bridge/log"
9+
lru "github.com/hashicorp/golang-lru/v2"
10+
)
11+
12+
// vaultWaveformHandler proxies Vidispine's pre-rendered waveform PNG endpoint
13+
// (/item/{vxid}/waveform/image), with an LRU cache. Item shape analysis must
14+
// have been performed upstream — items without analysis return upstream's
15+
// error (typically 404).
16+
// GET /vault/waveform?vxid=VX-123[&width=400&height=80&bgcolor=000000&fgcolor=ffffff].
17+
type vaultWaveformHandler struct {
18+
vault *VaultAPI
19+
cache *lru.Cache[string, []byte]
20+
}
21+
22+
func newVaultWaveformHandler(vault *VaultAPI) *vaultWaveformHandler {
23+
cache, _ := lru.New[string, []byte](2048)
24+
return &vaultWaveformHandler{vault: vault, cache: cache}
25+
}
26+
27+
// hexColor is 6 lowercase hex chars; the # prefix is added before forwarding
28+
// to Vidispine. Anything else is dropped so we don't pass garbage upstream.
29+
var hexColor = regexp.MustCompile(`^[0-9a-fA-F]{6}$`)
30+
31+
func (h *vaultWaveformHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
32+
if !PermissionsForEmail(getEmailFromHttp(r)).CanVault() {
33+
http.Error(w, "forbidden", http.StatusForbidden)
34+
return
35+
}
36+
vxID := r.URL.Query().Get("vxid")
37+
if vxID == "" {
38+
http.Error(w, "missing vxid", http.StatusBadRequest)
39+
return
40+
}
41+
42+
width := clampInt(r.URL.Query().Get("width"), 400, 1, 2000)
43+
height := clampInt(r.URL.Query().Get("height"), 80, 8, 400)
44+
bgColor := sanitizeColor(r.URL.Query().Get("bgcolor"))
45+
fgColor := sanitizeColor(r.URL.Query().Get("fgcolor"))
46+
47+
cacheKey := vxID + "|" + strconv.Itoa(width) + "x" + strconv.Itoa(height) + "|" + bgColor + "|" + fgColor
48+
if data, ok := h.cache.Get(cacheKey); ok {
49+
writeWaveform(w, data, "HIT")
50+
return
51+
}
52+
53+
data, err := h.vault.fetchWaveform(vxID, width, height, bgColor, fgColor)
54+
if err != nil {
55+
log.L.Debug().Err(err).Str("vxid", vxID).Msg("vault waveform: fetch failed")
56+
http.Error(w, "no waveform", http.StatusNotFound)
57+
return
58+
}
59+
60+
h.cache.Add(cacheKey, data)
61+
writeWaveform(w, data, "MISS")
62+
}
63+
64+
func sanitizeColor(s string) string {
65+
if !hexColor.MatchString(s) {
66+
return ""
67+
}
68+
return "#" + s
69+
}
70+
71+
func writeWaveform(w http.ResponseWriter, data []byte, cacheStatus string) {
72+
w.Header().Set("Content-Type", "image/png")
73+
w.Header().Set("Cache-Control", "private, max-age=86400")
74+
w.Header().Set("X-Cache", cacheStatus)
75+
_, _ = w.Write(data)
76+
}
77+
78+
func clampInt(s string, def, min, max int) int {
79+
if s == "" {
80+
return def
81+
}
82+
n, err := strconv.Atoi(s)
83+
if err != nil {
84+
return def
85+
}
86+
if n < min {
87+
return min
88+
}
89+
if n > max {
90+
return max
91+
}
92+
return n
93+
}

frontend/app/components/vault/VaultCard.vue

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,20 @@ const previewSrc = computed(
3333
`${props.base}/vault/image?vxid=${encodeURIComponent(props.item.VXID)}&width=400`,
3434
);
3535
36-
const imgSrc = computed(() =>
37-
imgStage.value === "thumbnail" ? thumbSrc.value : previewSrc.value,
36+
// /vault/waveform proxies Vidispine's pre-rendered waveform PNG. Black bg +
37+
// white fg renders as a transparent waveform when combined with
38+
// `mix-blend-mode: lighten` on a darker card background.
39+
const waveformSrc = computed(
40+
() =>
41+
`${props.base}/vault/waveform?vxid=${encodeURIComponent(props.item.VXID)}&width=400&height=160&bgcolor=000000&fgcolor=ffffff`,
3842
);
3943
44+
const imgSrc = computed(() => {
45+
if (imgStage.value === "failed") return undefined;
46+
if (props.item.mediaType === "audio") return waveformSrc.value;
47+
return imgStage.value === "thumbnail" ? thumbSrc.value : previewSrc.value;
48+
});
49+
4050
function onImgError() {
4151
if (imgStage.value === "thumbnail" && props.item.mediaType === "image") {
4252
imgStage.value = "preview";
@@ -99,7 +109,10 @@ function onLeave() {
99109
:src="imgSrc"
100110
:alt="item.title"
101111
loading="lazy"
102-
class="h-full w-full object-contain"
112+
:class="[
113+
'h-full w-full object-contain',
114+
item.mediaType === 'audio' && 'mix-blend-lighten',
115+
]"
103116
@error="onImgError"
104117
/>
105118
<UIcon v-else :name="typeIcon" class="size-10 opacity-40" />

frontend/app/pages/vault/[id].vue

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const previewSrc = computed(
3434
const thumbSrc = computed(
3535
() => `${base}/vault/thumbnail?vxid=${encodeURIComponent(vxId.value)}`,
3636
);
37+
const waveformSrc = computed(
38+
() =>
39+
`${base}/vault/waveform?vxid=${encodeURIComponent(vxId.value)}&width=1200&height=240&bgcolor=000000&fgcolor=ffffff`,
40+
);
3741
3842
const isVideo = computed(() => item.value?.mediaType === "video");
3943
const isAudio = computed(() => item.value?.mediaType === "audio");
@@ -46,6 +50,10 @@ const imageSrc = computed(() =>
4650
imageFailed.value ? thumbSrc.value : previewSrc.value,
4751
);
4852
53+
// Vidispine returns 404 if the item hasn't been analyzed yet — fall back to
54+
// the icon in that case.
55+
const waveformFailed = ref(false);
56+
4957
const bigIcon = computed(() => {
5058
switch (item.value?.mediaType) {
5159
case "video":
@@ -105,7 +113,18 @@ const lengthLabel = computed(() => {
105113
v-else-if="isAudio"
106114
class="bg-muted text-muted flex aspect-video w-full flex-col items-center justify-center gap-6 p-6"
107115
>
108-
<UIcon :name="bigIcon" class="size-14 opacity-40" />
116+
<img
117+
v-if="!waveformFailed"
118+
:src="waveformSrc"
119+
alt=""
120+
class="w-full max-w-3xl mix-blend-lighten"
121+
@error="waveformFailed = true"
122+
/>
123+
<UIcon
124+
v-else
125+
:name="bigIcon"
126+
class="size-14 opacity-40"
127+
/>
109128
<audio
110129
:src="previewSrc"
111130
controls

0 commit comments

Comments
 (0)