Changeset 6dd26e5 in Klonkt
- Timestamp:
- 07/30/2026 11:48:53 AM (6 weeks ago)
- Branches:
- main
- Children:
- 1f640ff
- Parents:
- 67f7150
- Files:
-
- 6 edited
-
src/assets/css/style.css (modified) (1 diff)
-
src/routes/activitypub.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
src/views/partials/post-card.ejs (modified) (6 diffs)
-
src/views/partials/post-tile.ejs (modified) (3 diffs)
-
test/c2s-compose.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/css/style.css
r67f7150 r6dd26e5 2421 2421 .grid-tile-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; display: block; transition: opacity .4s ease; } 2422 2422 .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 } 2423 2433 /* Cover videos are decorative, not players: let clicks + right-click pass through to the 2424 2434 tile link so you get the normal link menu (not the browser's <video> menu/controls). -
src/routes/activitypub.js
r67f7150 r6dd26e5 354 354 }).catch(() => { /* never blocks the upload */ }); 355 355 } 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 } 356 369 res.status(201).json({ 357 370 url: '/media/reply-media/' + req.file.filename, -
src/services/ActivityPubService.js
r67f7150 r6dd26e5 2441 2441 .map((a) => { 2442 2442 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) { 2447 2450 try { 2448 2451 const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media'); 2449 2452 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; 2451 2454 } catch { /* no poster is fine */ } 2452 2455 } … … 2461 2464 const mediaHtml = media.map((a) => { 2462 2465 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>`; 2464 2469 const poster = a.poster ? ` poster="${a.poster}"` : ''; 2465 2470 return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`; -
src/views/partials/post-card.ejs
r67f7150 r6dd26e5 19 19 // Content-derived fallback (see post-tile.ejs): a C2S post's media lives in 20 20 // its content, and the card fronts it from there. 21 let _cVideo = null, _cPoster = null, _cImg = null ;21 let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null; 22 22 if (!_realCover && post.content) { 23 23 const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i); 24 24 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 } 36 const _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)". 39 let _title = post.title || ''; 40 if (!_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 } 44 if (!_title) _title = _cAudio ? '♪ audio' : _cVideo ? '▶ video' : '(zonder titel)'; 28 45 const _typeLabel = (post.type && post.type !== 'overig' && post.type !== 'post') ? post.type : ''; 29 46 const _isPinned = !!post.pinned; … … 57 74 <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>" 58 75 <% 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"> 60 77 <% if (post.cover_image_url) { %><img src="<%= thumb(post.cover_image_url, 480) %>" 61 78 srcset="<%= thumb(post.cover_image_url, 320) %> 320w, <%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w" 62 79 sizes="(min-width: 768px) 120px, 100vw" 63 80 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"><% } %> 65 82 <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %> 66 83 </a> … … 68 85 <a class="post-list-cover nsfw-media" href="<%= _href %>" 69 86 <% 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"> 71 88 <span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span> 72 89 </a> … … 89 106 <a href="<%= _href %>" 90 107 <% 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 %> 92 109 </a> 93 110 </h3> … … 189 206 } 190 207 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) */ 191 211 /* Pinned indicator */ 192 212 .post-list-item.is-pinned .post-list-title a { … … 233 253 234 254 /* ============================================================ 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 /* ============================================================ 235 269 PIN + DRAFT INDICATORS (shared) 236 270 ============================================================ */ -
src/views/partials/post-tile.ejs
r67f7150 r6dd26e5 11 11 // content instead, like the Cirkel card: first <video> (with its poster) or 12 12 // first <img>. 13 let _cVideo = null, _cPoster = null, _cImg = null ;13 let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null; 14 14 if (!_hasCover && post.content) { 15 15 const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i); 16 16 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 } 18 27 } 19 const _showCover = _hasCover || !!_cVideo || !!_cImg; 28 const _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. 32 let _title = post.title || ''; 33 if (!_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 } 37 if (!_title) _title = _cAudio ? '♪ audio' : (_cVideo || _cImg) ? '' : '(untitled)'; 20 38 const _typeLabel = (post.type && post.type !== 'post') ? post.type : ''; 21 39 const _src = post.source_name || ''; // bron-site (cirkel-feed: van welke site komt deze post) … … 30 48 <% 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"<% } %>> 31 49 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"><% } %> 33 51 34 52 <% if (_typeLabel) { %> … … 43 61 <% if (_hasCover) { %> 44 62 <div class="grid-tile-overlay"> 45 <strong><%= post.title || '(untitled)'%></strong>63 <strong><%= _title %></strong> 46 64 <% if (_src) { %><span class="grid-tile-overlay-src">van <%= _src %></span><% } %> 47 65 </div> 48 66 <% } else { %> 49 <span class="grid-tile-title"><%= post.title || '(untitled)'%></span>67 <span class="grid-tile-title"><%= _title %></span> 50 68 <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %> 51 69 <% } %> -
test/c2s-compose.test.js
r67f7150 r6dd26e5 102 102 }); 103 103 104 test("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 104 139 test('a video without a poster simply has none: no guessed icon', async () => { 105 140 const r = await AP.ingestOutboxActivity(site, user, {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)