Changeset 6dd26e5 in Klonkt


Ignore:
Timestamp:
07/30/2026 11:48:53 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
1f640ff
Parents:
67f7150
Message:

De audio-tegel opgevrolijkt: golfvorm als thumbnail en een echte titel

Een audiopost uit Shaer was op Klonkt een kale gradient met (untitled)
(Robins schermafdruk, 30-7). Twee dingen tegelijk gefixt.

De thumbnail: de upload-leg tekent bij audio nu een golfvorm met de
gebundelde ffmpeg (showwavespic, wit op transparant) naar
<naam>.poster.png, precies zoals video zijn stilstaand beeld krijgt. Die
golfvorm rijdt mee als data-poster op de audio-tag (voor de tegels), in
c2s_attachments en als AS2-icon op het gefedereerde Audio-attachment. De
tegel toont hem OVER de eigen hue-gradient, dus elke audiopost houdt
zijn kleur en krijgt zijn vorm.

De titel: C2S-posts hebben geen titel, dus elke Shaer-post toonde
(untitled). Tegels en kaarten leiden de titel nu af: eerst de eigen
woorden van de post (afgekapt op 64), anders een vriendelijk typelabel
(muzieknoot audio, pijl video), en pas als er echt niets is (untitled).
Een tegel die zijn beeld al toont forceert geen label meer.

Changed files:
src/routes/activitypub.js

  • uploadMedia: audio/* -> showwavespic-golfvorm naar .poster.png (best-effort, zelfde patroon als de videoposter)

src/services/ActivityPubService.js

  • c2sCreatePost: posterExt per type (video .jpg, audio .png); de audio-tag krijgt data-poster

src/views/partials/post-tile.ejs

  • audio-detectie + golfvorm over de gradient; titelcascade (titel -> tekst -> typelabel)

src/views/partials/post-card.ejs

  • zelfde spiegel: golfvorm als cover met eigen accent-gradient, zelfde titelcascade

src/assets/css/style.css

  • .grid-tile-wave: contain, padding, transparant op de gradient

test/c2s-compose.test.js

  • audio-golfvormketen: data-poster op de tag, poster in de store, icon op het gefedereerde attachment

remarks: bestaande audioposts kregen hun upload voor deze code en hebben
dus nog geen golfvorm-bestand; die tonen nu wel het muzieknoot-label. Een
verse audiopost laat het hele effect zien. ffmpeg-commando en beide
templates hier lokaal gesmoke-test (RGBA-png, render ok), 345 tests
groen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Files:
6 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    r67f7150 r6dd26e5  
    24212421.grid-tile-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; display: block; transition: opacity .4s ease; }
    24222422.grid-tile-img.is-loading { opacity: 0; }
     2423/* Audio tile (Shaer, Robins vraag 30-7): the waveform PNG is white on
     2424   transparent and floats OVER the tile's own hue gradient, so every audio
     2425   post keeps its color and gets its shape. Contain + padding leaves the
     2426   centered title readable. */
     2427.grid-tile-img.grid-tile-wave {
     2428    background-color: transparent;
     2429    object-fit: contain;
     2430    padding: 26% 10%;
     2431    opacity: .85;
     2432}
    24232433/* Cover videos are decorative, not players: let clicks + right-click pass through to the
    24242434   tile link so you get the normal link menu (not the browser's <video> menu/controls).
  • src/routes/activitypub.js

    r67f7150 r6dd26e5  
    354354      }).catch(() => { /* never blocks the upload */ });
    355355    }
     356    // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
     357    // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
     358    // White on transparent, so the tile's own gradient stays the backdrop
     359    // and every audio post keeps its own hue.
     360    if (mime.startsWith('audio/')) {
     361      Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
     362        const bin = process.env.FFMPEG_PATH || ff.default;
     363        if (!bin) return;
     364        const poster = req.file.path + '.poster.png';
     365        execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', 'showwavespic=s=800x256:colors=white', '-frames:v', '1', poster],
     366          { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
     367      }).catch(() => { /* never blocks the upload */ });
     368    }
    356369    res.status(201).json({
    357370      url: '/media/reply-media/' + req.file.filename,
  • src/services/ActivityPubService.js

    r67f7150 r6dd26e5  
    24412441    .map((a) => {
    24422442      const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
    2443       // A video's poster frame, when the upload leg made one (shaer-zowq):
    2444       // rides along so the tag, the federated attachment and the apps all
    2445       // show a still instead of a black box.
    2446       if (entry.mediaType.startsWith('video/')) {
     2443      // The poster the upload leg made, when it did: a video's still frame
     2444      // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
     2445      // .poster.png). Rides along so the tag, the federated attachment and
     2446      // the apps all have something to show instead of a bare box.
     2447      const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
     2448        : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
     2449      if (posterExt) {
    24472450        try {
    24482451          const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
    24492452          const rel = entry.url.replace(/^\/media\//, '');
    2450           if (fs.existsSync(path.join(mediaRoot, rel + '.poster.jpg'))) entry.poster = entry.url + '.poster.jpg';
     2453          if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
    24512454        } catch { /* no poster is fine */ }
    24522455      }
     
    24612464  const mediaHtml = media.map((a) => {
    24622465    if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
    2463     if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata" src="${a.url}"></audio></p>`;
     2466    // data-poster: <audio> has no poster attribute, but the tile derivation
     2467    // reads this one to show the waveform (post-tile/post-card).
     2468    if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
    24642469    const poster = a.poster ? ` poster="${a.poster}"` : '';
    24652470    return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
  • src/views/partials/post-card.ejs

    r67f7150 r6dd26e5  
    1919// Content-derived fallback (see post-tile.ejs): a C2S post's media lives in
    2020// its content, and the card fronts it from there.
    21 let _cVideo = null, _cPoster = null, _cImg = null;
     21let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
    2222if (!_realCover && post.content) {
    2323  const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
    2424  if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
    25   else { const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i); if (im) _cImg = im[1]; }
    26 }
    27 const _hasCover  = _realCover || !!_cVideo || !!_cImg;
     25  else {
     26    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
     27    if (im) _cImg = im[1];
     28    else {
     29      // An audio post (Robins vraag, 30-7): the upload leg drew a waveform
     30      // (data-poster on the tag); the card fronts it like a cover.
     31      const am = String(post.content).match(/<audio[^>]*>/i);
     32      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
     33    }
     34  }
     35}
     36const _hasCover  = _realCover || !!_cVideo || !!_cImg || !!_cWave;
     37// A C2S post has no title: fall back to the post's own words, then to a
     38// friendly type label instead of "(zonder titel)".
     39let _title = post.title || '';
     40if (!_title && post.content) {
     41  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
     42  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
     43}
     44if (!_title) _title = _cAudio ? '♪ audio' : _cVideo ? '▶ video' : '(zonder titel)';
    2845const _typeLabel = (post.type && post.type !== 'overig' && post.type !== 'post') ? post.type : '';
    2946const _isPinned  = !!post.pinned;
     
    5774    <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>"
    5875       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
    59        aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
     76       aria-label="<%= _title %>" tabindex="-1">
    6077      <% if (post.cover_image_url) { %><img src="<%= thumb(post.cover_image_url, 480) %>"
    6178           srcset="<%= thumb(post.cover_image_url, 320) %> 320w, <%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w"
    6279           sizes="(min-width: 768px) 120px, 100vw"
    6380           alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
    64       <% } else if (post.cover_video_url) { %><video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } %>
     81      <% } else if (post.cover_video_url) { %><video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="post-cover-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
    6582      <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %>
    6683    </a>
     
    6885    <a class="post-list-cover nsfw-media" href="<%= _href %>"
    6986       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
    70        aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
     87       aria-label="<%= _title %>" tabindex="-1">
    7188      <span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span>
    7289    </a>
     
    89106      <a href="<%= _href %>"
    90107         <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
    91         <%= post.title || '(zonder titel)' %>
     108        <%= _title %>
    92109      </a>
    93110    </h3>
     
    189206  }
    190207
     208  /* Audio cover (Shaer): white-on-transparent waveform on its own accent
     209     gradient, contained so the shape reads at 120px and full-bleed alike. */
     210  /* (see also .grid-tile-wave in style.css for the grid tiles) */
    191211  /* Pinned indicator */
    192212  .post-list-item.is-pinned .post-list-title a {
     
    233253
    234254/* ============================================================
     255   AUDIO COVER (shared) — Shaer audio post (Robins vraag, 30-7):
     256   the waveform PNG is white on transparent; give it its own accent
     257   gradient and contain it so the shape reads at 120px and full-bleed.
     258   ============================================================ */
     259.post-list-cover .post-cover-wave {
     260  width: 100%; height: 100%;
     261  object-fit: contain !important;
     262  padding: 14% 8%;
     263  background-image: linear-gradient(135deg,
     264      color-mix(in srgb, var(--accent, #888) 26%, var(--paper-2, var(--paper))) 0%,
     265      color-mix(in srgb, var(--accent, #888) 8%, var(--paper-2, var(--paper))) 100%);
     266}
     267
     268/* ============================================================
    235269   PIN + DRAFT INDICATORS (shared)
    236270   ============================================================ */
  • src/views/partials/post-tile.ejs

    r67f7150 r6dd26e5  
    1111// content instead, like the Cirkel card: first <video> (with its poster) or
    1212// first <img>.
    13 let _cVideo = null, _cPoster = null, _cImg = null;
     13let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
    1414if (!_hasCover && post.content) {
    1515  const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
    1616  if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
    17   else { const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i); if (im) _cImg = im[1]; }
     17  else {
     18    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
     19    if (im) _cImg = im[1];
     20    else {
     21      // An audio post (Robins vraag, 30-7): the waveform the upload leg drew
     22      // (data-poster) goes OVER the gradient, so the tile keeps its own hue.
     23      const am = String(post.content).match(/<audio[^>]*>/i);
     24      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
     25    }
     26  }
    1827}
    19 const _showCover = _hasCover || !!_cVideo || !!_cImg;
     28const _showCover = _hasCover || !!_cVideo || !!_cImg;   // the wave keeps the gradient
     29// A C2S post has no title: fall back to the post's own words, and for a
     30// media-only post to a friendly type label instead of "(untitled)". A tile
     31// that already shows its picture needs no forced label at all.
     32let _title = post.title || '';
     33if (!_title && post.content) {
     34  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
     35  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
     36}
     37if (!_title) _title = _cAudio ? '♪ audio' : (_cVideo || _cImg) ? '' : '(untitled)';
    2038const _typeLabel = (post.type && post.type !== 'post') ? post.type : '';
    2139const _src = post.source_name || '';   // bron-site (cirkel-feed: van welke site komt deze post)
     
    3048   <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
    3149
    32   <% if (post.cover_image_url) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>><% } else if (post.cover_video_url) { %><video class="grid-tile-img" src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video class="grid-tile-img" src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img class="grid-tile-img" src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } %>
     50  <% if (post.cover_image_url) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>><% } else if (post.cover_video_url) { %><video class="grid-tile-img" src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video class="grid-tile-img" src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img class="grid-tile-img" src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="grid-tile-img grid-tile-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
    3351
    3452  <% if (_typeLabel) { %>
     
    4361  <% if (_hasCover) { %>
    4462    <div class="grid-tile-overlay">
    45       <strong><%= post.title || '(untitled)' %></strong>
     63      <strong><%= _title %></strong>
    4664      <% if (_src) { %><span class="grid-tile-overlay-src">van <%= _src %></span><% } %>
    4765    </div>
    4866  <% } else { %>
    49     <span class="grid-tile-title"><%= post.title || '(untitled)' %></span>
     67    <span class="grid-tile-title"><%= _title %></span>
    5068    <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %>
    5169  <% } %>
  • test/c2s-compose.test.js

    r67f7150 r6dd26e5  
    102102});
    103103
     104test("an audio note's waveform rides the tag, the store and the federated attachment", async () => {
     105  // Same courtesy as the video poster (Robins vraag, 30-7: de kale
     106  // audio-tegel): the upload leg draws <name>.poster.png (showwavespic).
     107  // From there it must reach the data-poster on the folded tag (the tiles
     108  // read it), the stored entry, and the AS2 icon on the federated Audio.
     109  const os = await import('os');
     110  const fsm = await import('fs');
     111  const pathm = await import('path');
     112  const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
     113  fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
     114  fsm.writeFileSync(pathm.join(root, 'reply-media', 'lied.m4a.poster.png'), 'x');
     115  const prev = process.env.MEDIA_PATH;
     116  process.env.MEDIA_PATH = root;
     117  try {
     118    const r = await AP.ingestOutboxActivity(site, user, {
     119      type: 'Create',
     120      object: {
     121        type: 'Note', content: '',
     122        to: ['https://test.example/ap/users/kid/followers'],
     123        attachment: [{ type: 'Audio', url: '/media/reply-media/lied.m4a', mediaType: 'audio/mp4' }],
     124      },
     125    });
     126    assert.equal(r.status, 201);
     127    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
     128    assert.match(post.content, /data-poster="\/media\/reply-media\/lied\.m4a\.poster\.png"/, 'the tag carries the waveform for the tiles');
     129    const note = AP.buildNote('https://test.example', site, post);
     130    const aud = (note.attachment || []).find((a) => a.url.endsWith('lied.m4a'));
     131    assert.ok(aud && aud.type === 'Audio');
     132    assert.equal(aud.icon && aud.icon.url, 'https://test.example/media/reply-media/lied.m4a.poster.png',
     133      'the waveform federates as the attachment icon');
     134  } finally {
     135    if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
     136  }
     137});
     138
    104139test('a video without a poster simply has none: no guessed icon', async () => {
    105140  const r = await AP.ingestOutboxActivity(site, user, {
Note: See TracChangeset for help on using the changeset viewer.