Changeset 7d01696 in Klonkt


Ignore:
Timestamp:
07/30/2026 07:56:33 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
9ed940e
Parents:
97bcf7e
Message:

Posterframes voor video, ffmpeg-optioneel

shaer-zowq, de serverhelft. Bij een video-upload trekt ffmpeg een frame op
1 seconde naar <naam>.poster.jpg, best-effort en buiten de responspad: op een
machine zonder ffmpeg gebeurt er niets en breekt er niets (de clients halen
dan zelf een frame, native). Vanaf daar reist de poster naar drie plekken:

  • poster= op de gevouwen video-tag, dus het web toont een stilstaand beeld
  • opgeslagen op de c2s_attachments-entry
  • als AS2 icon op het gefedereerde Video-attachment, dus apps en andere servers krijgen de thumbnail-URL cadeau

Inkomend geldt het spiegelbeeld: een remote video-attachment met een icon
houdt hem als poster in media_json, en de C2S-inboxlees serveert hem terug.

Changed files:
src/routes/activitypub.js

  • uploadMedia: ffmpeg-posterframe voor video, best-effort

src/services/ActivityPubService.js

  • c2sCreatePost: poster op entry en tag; buildNote: icon op het attachment; mediaFromNote/timelineAttachments: inkomende posters door

test/c2s-compose.test.js

  • de poster bereikt tag, opslag en attachment; zonder posterbestand wordt er niets gegokt

remarks: 338 tests groen. ffmpeg staat NIET op de VPS; installeren is een
sudo-actie die ik niet mag doen: sudo apt-get install -y ffmpeg. Zonder dat
werkt alles, alleen maken de clients hun thumbnails zelf.

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

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r97bcf7e r7d01696  
    312312      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
    313313    }
     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    }
    314325    res.status(201).json({
    315326      url: '/media/reply-media/' + req.file.filename,
  • src/services/ActivityPubService.js

    r97bcf7e r7d01696  
    1717 */
    1818import crypto from 'crypto';
     19import fs from 'fs';
     20import path from 'path';
    1921import dns from 'dns';
    2022import net from 'net';
     
    380382  try {
    381383    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 });
    383385    }
    384386  } catch { /* malformed never blocks the Note */ }
     
    480482      const a = { type: ty, mediaType: mt, url: x.url };
    481483      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)
    482485      return a; });
    483486  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
     
    23792382      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
    23802383    .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    });
    23822398  if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
    23832399  // The web reads the post's content, so the media goes IN it (we build these
     
    23892405    if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
    23902406    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>`;
    23922409  }).join('');
    23932410  const postId = crypto.randomUUID();
     
    28182835    const rows = (Array.isArray(list) ? list : [])
    28192836      .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      });
    28212842    return rows.length ? rows : undefined;
    28222843  } catch { return undefined; }
     
    30273048}
    30283049function 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);
    30303057  if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
    30313058    const im = Array.isArray(note.image) ? note.image[0] : note.image;
  • test/c2s-compose.test.js

    r97bcf7e r7d01696  
    5555});
    5656
     57test("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
     92test('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
    57107test('a media-only post is a post, not an empty-note error', async () => {
    58108  const r = await AP.ingestOutboxActivity(site, user, {
Note: See TracChangeset for help on using the changeset viewer.