Changeset 7d01696 in Klonkt
- Timestamp:
- 07/30/2026 07:56:33 AM (6 weeks ago)
- Branches:
- main
- Children:
- 9ed940e
- Parents:
- 97bcf7e
- Files:
-
- 3 edited
-
src/routes/activitypub.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (7 diffs)
-
test/c2s-compose.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r97bcf7e r7d01696 312 312 return res.status(400).json({ error: 'Media must be an image, audio or video file' }); 313 313 } 314 // A video gets a poster frame next to it (shaer-zowq), best-effort and 315 // out of band: ffmpeg pulls one frame at 1s into <name>.poster.jpg. On a 316 // machine without ffmpeg nothing happens and nothing breaks; the clients 317 // fall back to extracting a frame natively. 318 if (mime.startsWith('video/')) { 319 import('child_process').then(({ execFile }) => { 320 const poster = req.file.path + '.poster.jpg'; 321 execFile('ffmpeg', ['-y', '-ss', '1', '-i', req.file.path, '-frames:v', '1', '-vf', "scale='min(640,iw)':-2", poster], 322 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] poster failed:', e.message); }); 323 }).catch(() => { /* never blocks the upload */ }); 324 } 314 325 res.status(201).json({ 315 326 url: '/media/reply-media/' + req.file.filename, -
src/services/ActivityPubService.js
r97bcf7e r7d01696 17 17 */ 18 18 import crypto from 'crypto'; 19 import fs from 'fs'; 20 import path from 'path'; 19 21 import dns from 'dns'; 20 22 import net from 'net'; … … 380 382 try { 381 383 for (const a of JSON.parse(post.c2s_attachments || '[]')) { 382 if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType });384 if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType, poster: a.poster ? abs(a.poster) : null }); 383 385 } 384 386 } catch { /* malformed never blocks the Note */ } … … 480 482 const a = { type: ty, mediaType: mt, url: x.url }; 481 483 if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`) 484 if (x.poster) a.icon = { type: 'Image', url: x.poster }; // the video's still (shaer-zowq) 482 485 return a; }); 483 486 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players … … 2379 2382 && /^(image|audio|video)\//.test(String(a.mediaType || ''))) 2380 2383 .slice(0, 4) 2381 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) })); 2384 .map((a) => { 2385 const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }; 2386 // A video's poster frame, when the upload leg made one (shaer-zowq): 2387 // rides along so the tag, the federated attachment and the apps all 2388 // show a still instead of a black box. 2389 if (entry.mediaType.startsWith('video/')) { 2390 try { 2391 const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media'); 2392 const rel = entry.url.replace(/^\/media\//, ''); 2393 if (fs.existsSync(path.join(mediaRoot, rel + '.poster.jpg'))) entry.poster = entry.url + '.poster.jpg'; 2394 } catch { /* no poster is fine */ } 2395 } 2396 return entry; 2397 }); 2382 2398 if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' }; 2383 2399 // The web reads the post's content, so the media goes IN it (we build these … … 2389 2405 if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`; 2390 2406 if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata" src="${a.url}"></audio></p>`; 2391 return `<p><video controls playsinline preload="metadata" src="${a.url}"></video></p>`; 2407 const poster = a.poster ? ` poster="${a.poster}"` : ''; 2408 return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`; 2392 2409 }).join(''); 2393 2410 const postId = crypto.randomUUID(); … … 2818 2835 const rows = (Array.isArray(list) ? list : []) 2819 2836 .filter((m) => m && m.url) 2820 .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url })); 2837 .map((m) => { 2838 const a = { type: 'Document', mediaType: m.type || undefined, url: m.url }; 2839 if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq) 2840 return a; 2841 }); 2821 2842 return rows.length ? rows : undefined; 2822 2843 } catch { return undefined; } … … 3027 3048 } 3028 3049 function mediaFromNote(note) { 3029 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url); 3050 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => { 3051 const m = { url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }; 3052 // A federated video may carry its poster as an AS2 icon (shaer-zowq). 3053 const iconUrl = a && a.icon && safeUrl(typeof a.icon === 'string' ? a.icon : a.icon.url); 3054 if (iconUrl && /^video\//i.test(m.type)) m.poster = iconUrl; 3055 return m; 3056 }).filter((m) => m.url); 3030 3057 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) { 3031 3058 const im = Array.isArray(note.image) ? note.image[0] : note.image; -
test/c2s-compose.test.js
r97bcf7e r7d01696 55 55 }); 56 56 57 test("a video's poster frame rides the tag, the store and the federated attachment", async () => { 58 // The upload leg writes <name>.poster.jpg next to a video when ffmpeg is 59 // around (shaer-zowq). From there it must reach three places: the poster= 60 // on the folded tag (web), the stored entry, and the AS2 icon on the 61 // federated attachment (apps and other servers). 62 const os = await import('os'); 63 const fsm = await import('fs'); 64 const pathm = await import('path'); 65 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-')); 66 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true }); 67 fsm.writeFileSync(pathm.join(root, 'reply-media', 'film.mp4.poster.jpg'), 'x'); 68 const prev = process.env.MEDIA_PATH; 69 process.env.MEDIA_PATH = root; 70 try { 71 const r = await AP.ingestOutboxActivity(site, user, { 72 type: 'Create', 73 object: { 74 type: 'Note', content: '<p>filmpje</p>', 75 to: ['https://test.example/ap/users/kid/followers'], 76 attachment: [{ type: 'Video', url: '/media/reply-media/film.mp4', mediaType: 'video/mp4' }], 77 }, 78 }); 79 assert.equal(r.status, 201); 80 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id); 81 assert.match(post.content, /poster="\/media\/reply-media\/film\.mp4\.poster\.jpg"/, 'the web tag shows the still'); 82 const note = AP.buildNote('https://test.example', site, post); 83 const vid = (note.attachment || []).find((a) => a.url.endsWith('film.mp4')); 84 assert.ok(vid && vid.type === 'Video'); 85 assert.equal(vid.icon && vid.icon.url, 'https://test.example/media/reply-media/film.mp4.poster.jpg', 86 'the poster federates as the attachment icon'); 87 } finally { 88 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev; 89 } 90 }); 91 92 test('a video without a poster simply has none: no guessed icon', async () => { 93 const r = await AP.ingestOutboxActivity(site, user, { 94 type: 'Create', 95 object: { 96 type: 'Note', content: '<p>kaal</p>', 97 to: ['https://test.example/ap/users/kid/followers'], 98 attachment: [{ type: 'Video', url: '/media/reply-media/zonder.mp4', mediaType: 'video/mp4' }], 99 }, 100 }); 101 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id); 102 assert.ok(!post.content.includes('poster='), 'no poster attr without a poster file'); 103 const vid = (AP.buildNote('https://test.example', site, post).attachment || []).find((a) => a.url.endsWith('zonder.mp4')); 104 assert.equal(vid.icon, undefined); 105 }); 106 57 107 test('a media-only post is a post, not an empty-note error', async () => { 58 108 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)