修复视频收藏/聊天记录缩略图恒为破损图片、时长换算错误

MediaThumb 把视频实体自身字节(.val/.ref,给 <video> 解码用)当图片
塞进 <img src>,浏览器无法解码,缩略图恒为损坏图标;改用后端已下发但
从未使用的 .preview/.premime 海报字段(lib/formatters.js 的
quoteImageOrVideo()/inlineVideoAttr() 早已用同一约定)。

时长换算:收藏页把毫秒当秒数传给 secondsToTime,2.4秒视频显示
"39:26";History 媒体宫格则完全没接上 duration 字段(mediaMessagePayload
是扁平结构,需要跟 history-files 一样重建 Drafty 实体才能取到)。

第十轮最小粒度测试 Ch7 发现(纠正第九轮"证据不足"的结论)。已用真实
收藏视频实测确认:缩略图正确解码(320x240/96x96),时长正确显示 0:02。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-06 00:49:04 +08:00
co-authored by Claude Sonnet 5
parent 9d2db5a648
commit bab1f13e04
+26 -6
View File
@@ -91,6 +91,14 @@ function resolveAttachmentUrl(att) {
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
}
// A video entity's .val/.ref (what resolveAttachmentUrl above resolves) is the video's own bytes --
// meant for a real <video> element, not decodable by a plain <img>. The poster frame lives in the
// separate .preview/.premime fields the backend already sends alongside it, same convention
// lib/formatters.js's quoteImageOrVideo()/inlineVideoAttr() rely on for the chat-bubble quote case.
function videoPosterUrl(att) {
if (!att || !att.preview) return null;
return 'data:' + (att.premime || 'image/jpeg') + ';base64,' + att.preview;
}
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
// reconstruct the Drafty shape from whatever ent/fmt/txt survived so the same attachment-extraction
@@ -165,13 +173,18 @@ function LinkText({url, onOpen, removed}) {
return <button type="button" className="library-link-text" onClick={() => onOpen && onOpen(url)}><Icon>link</Icon><span>{url}</span></button>;
}
function MediaThumb({url, video, duration, onOpen, removed}) {
function MediaThumb({url, poster, video, duration, onOpen, removed}) {
if (removed) {
return <div className="library-media-thumb removed"><Icon>{video ? 'videocam_off' : 'broken_image'}</Icon><em>{video ? '视频已失效' : '图片已失效'}</em></div>;
}
// video's own bytes (url) can't be decoded by <img> -- only the poster frame can. Images have no
// separate poster; their own url doubles as the thumbnail.
const imgSrc = video ? poster : url;
return <button type="button" className="library-media-thumb" onClick={onOpen}>
{url ? <img src={url} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
{video ? <><Icon>play_circle</Icon>{duration ? <small>{secondsToTime(duration)}</small> : null}</> : null}
{imgSrc ? <img src={imgSrc} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
{/* duration follows the AU/VD entity storage convention of milliseconds (see sendVideoAttachment/
insertVideo), same conversion widgets/audio-player.jsx already does for its own duration prop. */}
{video ? <><Icon>play_circle</Icon>{duration > 0 ? <small>{secondsToTime(duration / 1000)}</small> : null}</> : null}
</button>;
}
@@ -192,7 +205,8 @@ function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} removed={item.availability === 'removed'} /> : null}
{item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}
onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} duration={att && att.duration} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} poster={item.type === 'video' ? videoPosterUrl(att) : null}
video={item.type === 'video'} duration={att && att.duration} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
</div>
</article>;
@@ -629,8 +643,14 @@ function History({view, hashParams, myUserId, onToast}) {
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)}
onOpen={openLink} removed={item.availability === 'removed'} />
</React.Fragment>)}</section> :
<section className="library-media-grid">{list.map(item => <MediaThumb key={item.seq} url={mediaUrls[item.seq]} video={view === 'history-videos'}
duration={item.duration} onOpen={() => jump(item.seq)} removed={item.availability === 'removed'} />)}</section>}
<section className="library-media-grid">{list.map(item => {
// mediaMessagePayload (getTopicMedia) is flat and has no top-level `duration`/poster fields --
// the real values live inside the reconstructed Drafty entity, same as history-files' FileCard
// already reconstructs `att` for name/size/mime just above.
const videoAtt = view === 'history-videos' ? attachmentFromFlat(item) : null;
return <MediaThumb key={item.seq} url={mediaUrls[item.seq]} poster={videoAtt ? videoPosterUrl(videoAtt) : null} video={view === 'history-videos'}
duration={videoAtt && videoAtt.duration} onOpen={() => jump(item.seq)} removed={item.availability === 'removed'} />;
})}</section>}
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
</main>
</div>;