Changeset 15f1cb5 in Klonkt


Ignore:
Timestamp:
07/30/2026 08:09:51 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
2a10445
Parents:
79f00c5
Message:

De tegel speelt de video als zijn eigen thumbnail

Robins aanwijzing (30-7) bij de (untitled)-gradient: een videopost uit de app
mag in het Klonkt-grid de video zelf als thumbnail afspelen. De machinerie
bestond al (animated covers renderen als muted-loop video in de tegel);
C2S-posts zetten alleen nooit een cover.

Nu promoveert de eerste video van een C2S-post tot cover_video_url, met
bewust GEEN cover-image ernaast: de image-tak van de tegel zou winnen en de
tegel bevriezen. Een fotopost krijgt zijn eerste foto als cover, dus ook die
verliest de (untitled)-gradient.

In buildNote schuiven de C2S-entries voor de cover-push: de URL-dedupe houdt
de eerste, en dat moet de entry zijn die zijn mediaType en poster kent,
anders verloor de federatie het icon zodra de video ook cover werd.

Changed files:
src/services/ActivityPubService.js

  • c2sCreatePost: eerste video wordt cover-video, eerste foto cover-image
  • buildNote: C2S-entries voor de covers, zodat de rijke entry wint

test/c2s-compose.test.js

  • de tegelbelofte: cover-video gezet, geen cover-image ernaast, en de dedupe houdt de entry met de poster

remarks: 338 tests groen.

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

Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r79f00c5 r15f1cb5  
    374374  // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
    375375  // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
    376   if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
    377   else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
    378376  // Media a C2S composer attached (shaer-j3uh): federate with their REAL
    379377  // mediaType, because the extension map below knows no audio and would call
    380   // an m4a an Image. Images also live inline in the content, so the dedupe
    381   // by URL keeps them single.
     378  // an m4a an Image. Pushed BEFORE the covers: a C2S video doubles as the
     379  // cover video, and the URL-dedupe keeps the FIRST entry, which must be the
     380  // one that knows its type and poster. Images also live inline in the
     381  // content, so the dedupe keeps those single too.
    382382  try {
    383383    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
     
    385385    }
    386386  } catch { /* malformed never blocks the Note */ }
     387  if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
     388  else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
    387389  let body = post.content || '';
    388390  // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
     
    24082410    return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
    24092411  }).join('');
     2412  // The web tile (Robins aanwijzing, 30-7): a video post's first video
     2413  // becomes the cover video, so the grid plays it muted-looping as its own
     2414  // thumbnail, exactly like an animated cover. Deliberately NO cover image
     2415  // next to it: the tile's image branch would win and freeze the tile. A
     2416  // photo post gets its first image as cover, instead of the (untitled)
     2417  // gradient.
     2418  const coverVid = media.find((a) => a.mediaType.startsWith('video/'));
     2419  const coverImg = media.find((a) => a.mediaType.startsWith('image/'));
    24102420  const postId = crypto.randomUUID();
    24112421  const slug = 'n-' + postId.slice(0, 8);
     
    24182428  const vis = c2sVisibility(object);
    24192429  const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
    2420   db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
    2421               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
    2422     .run(postId, site.id, slug, user.id, '', html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
     2430  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, cover_image_url, cover_video_url, created_at, updated_at, published_at)
     2431              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
     2432    .run(postId, site.id, slug, user.id, '', html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis,
     2433      coverVid ? null : (coverImg ? coverImg.url : null), coverVid ? coverVid.url : null, now, now, now);
    24232434  if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
    24242435  try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
     
    24262437  try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
    24272438  if (vis !== 'direct') {
    2428     deliverCreate(site, { id: postId, slug, title: '', content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ });
     2439    deliverCreate(site, { id: postId, slug, title: '', content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, cover_image_url: coverVid ? null : (coverImg ? coverImg.url : null), cover_video_url: coverVid ? coverVid.url : null, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ });
    24292440  }
    24302441  return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
  • test/c2s-compose.test.js

    r79f00c5 r15f1cb5  
    8585    assert.equal(vid.icon && vid.icon.url, 'https://test.example/media/reply-media/film.mp4.poster.jpg',
    8686      'the poster federates as the attachment icon');
     87    // The web tile plays the video as its own thumbnail (Robins aanwijzing):
     88    // the first video becomes the cover video, with NO cover image beside it,
     89    // because the tile's image branch would win and freeze the tile.
     90    assert.equal(post.cover_video_url, '/media/reply-media/film.mp4');
     91    assert.equal(post.cover_image_url, null);
     92    assert.equal((note.attachment || []).filter((a) => a.url.endsWith('film.mp4')).length, 1,
     93      'cover video + attachment dedupe to the one entry that knows its poster');
    8794  } finally {
    8895    if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
     
    115122  });
    116123  assert.equal(r.status, 201, 'a picture can be the whole message');
     124  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
     125  assert.equal(post.cover_image_url, '/media/reply-media/alleen.jpg',
     126    'a photo post fronts its photo instead of the (untitled) gradient');
    117127});
Note: See TracChangeset for help on using the changeset viewer.