修复视频收藏/聊天记录缩略图恒为破损图片、时长换算错误
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:
co-authored by
Claude Sonnet 5
parent
9d2db5a648
commit
bab1f13e04
@@ -91,6 +91,14 @@ function resolveAttachmentUrl(att) {
|
|||||||
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
|
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
|
||||||
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
|
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
|
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
|
||||||
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
|
// 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
|
// 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>;
|
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) {
|
if (removed) {
|
||||||
return <div className="library-media-thumb removed"><Icon>{video ? 'videocam_off' : 'broken_image'}</Icon><em>{video ? '视频已失效' : '图片已失效'}</em></div>;
|
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}>
|
return <button type="button" className="library-media-thumb" onClick={onOpen}>
|
||||||
{url ? <img src={url} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
|
{imgSrc ? <img src={imgSrc} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
|
||||||
{video ? <><Icon>play_circle</Icon>{duration ? <small>{secondsToTime(duration)}</small> : null}</> : null}
|
{/* 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>;
|
</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 === '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)}
|
{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}
|
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>
|
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
|
||||||
</div>
|
</div>
|
||||||
</article>;
|
</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)}
|
<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'} />
|
onOpen={openLink} removed={item.availability === 'removed'} />
|
||||||
</React.Fragment>)}</section> :
|
</React.Fragment>)}</section> :
|
||||||
<section className="library-media-grid">{list.map(item => <MediaThumb key={item.seq} url={mediaUrls[item.seq]} video={view === 'history-videos'}
|
<section className="library-media-grid">{list.map(item => {
|
||||||
duration={item.duration} onOpen={() => jump(item.seq)} removed={item.availability === 'removed'} />)}</section>}
|
// 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}
|
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
|
||||||
</main>
|
</main>
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
Reference in New Issue
Block a user