Process missing media dimensions

media-dim
Alex Gleason 2025-03-12 14:00:04 -05:00
rodzic 311eaddb29
commit ecdfa13053
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
11 zmienionych plików z 104 dodań i 39 usunięć

Wyświetl plik

@ -1,6 +1,6 @@
import { importEntities } from 'soapbox/entity-store/actions.ts'; import { importEntities } from 'soapbox/entity-store/actions.ts';
import { Entities } from 'soapbox/entity-store/entities.ts'; import { Entities } from 'soapbox/entity-store/entities.ts';
import { Group, accountSchema, groupSchema } from 'soapbox/schemas/index.ts'; import { Group, accountSchema, groupSchema, statusSchema } from 'soapbox/schemas/index.ts';
import { filteredArray } from 'soapbox/schemas/utils.ts'; import { filteredArray } from 'soapbox/schemas/utils.ts';
import { getSettings } from '../settings.ts'; import { getSettings } from '../settings.ts';
@ -95,9 +95,12 @@ const importFetchedGroups = (groups: APIEntity[]) => {
}; };
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) => const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
(dispatch: AppDispatch) => { async (dispatch: AppDispatch) => {
const result = await statusSchema.safeParseAsync(status);
// Skip broken statuses // Skip broken statuses
if (isBroken(status)) return; if (!result.success) return;
status = result.data;
if (status.reblog?.id) { if (status.reblog?.id) {
dispatch(importFetchedStatus(status.reblog)); dispatch(importFetchedStatus(status.reblog));
@ -135,31 +138,18 @@ const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
dispatch(importStatus(status, idempotencyKey)); dispatch(importStatus(status, idempotencyKey));
}; };
// Sometimes Pleroma can return an empty account,
// or a repost can appear of a deleted account. Skip these statuses.
const isBroken = (status: APIEntity) => {
try {
// Skip empty accounts
// https://gitlab.com/soapbox-pub/soapbox/-/issues/424
if (!status.account.id) return true;
// Skip broken reposts
// https://gitlab.com/soapbox-pub/rebased/-/issues/28
if (status.reblog && !status.reblog.account.id) return true;
return false;
} catch (e) {
return true;
}
};
const importFetchedStatuses = (statuses: APIEntity[]) => const importFetchedStatuses = (statuses: APIEntity[]) =>
(dispatch: AppDispatch, getState: () => RootState) => { async (dispatch: AppDispatch, getState: () => RootState) => {
const accounts: APIEntity[] = []; const accounts: APIEntity[] = [];
const normalStatuses: APIEntity[] = []; const normalStatuses: APIEntity[] = [];
const polls: APIEntity[] = []; const polls: APIEntity[] = [];
function processStatus(status: APIEntity) { async function processStatus(status: APIEntity) {
const result = await statusSchema.safeParseAsync(status);
// Skip broken statuses // Skip broken statuses
if (isBroken(status)) return; if (!result.success) return;
status = result.data;
normalStatuses.push(status); normalStatuses.push(status);
accounts.push(status.account); accounts.push(status.account);
@ -186,7 +176,7 @@ const importFetchedStatuses = (statuses: APIEntity[]) =>
} }
} }
statuses.forEach(processStatus); await Promise.all((statuses.map(processStatus)));
dispatch(importPolls(polls)); dispatch(importPolls(polls));
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));

Wyświetl plik

@ -173,7 +173,7 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
const { next, prev } = response.pagination(); const { next, prev } = response.pagination();
const data: APIEntity[] = await response.json(); const data: APIEntity[] = await response.json();
dispatch(importFetchedStatuses(data)); await dispatch(importFetchedStatuses(data));
const statusesFromGroups = (data as Status[]).filter((status) => !!status.group); const statusesFromGroups = (data as Status[]).filter((status) => !!status.group);
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));

Wyświetl plik

@ -59,7 +59,7 @@ function useBookmark() {
try { try {
const response = await api.post(`/api/v1/statuses/${statusId}/bookmark`); const response = await api.post(`/api/v1/statuses/${statusId}/bookmark`);
const result = statusSchema.parse(await response.json()); const result = await statusSchema.parseAsync(await response.json());
if (result) { if (result) {
dispatch(importEntities([result], Entities.STATUSES, 'bookmarks', 'start')); dispatch(importEntities([result], Entities.STATUSES, 'bookmarks', 'start'));
} }

Wyświetl plik

@ -67,7 +67,7 @@ export function useReaction() {
try { try {
const response = await api.put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`); const response = await api.put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`);
const result = statusSchema.parse(await response.json()); const result = await statusSchema.parseAsync(await response.json());
if (result) { if (result) {
dispatch(importEntities([result], Entities.STATUSES)); dispatch(importEntities([result], Entities.STATUSES));
} }
@ -82,7 +82,7 @@ export function useReaction() {
try { try {
const response = await api.delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`); const response = await api.delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`);
const result = statusSchema.parse(await response.json()); const result = await statusSchema.parseAsync(await response.json());
if (result) { if (result) {
dispatch(importEntities([result], Entities.STATUSES)); dispatch(importEntities([result], Entities.STATUSES));
} }

Wyświetl plik

@ -73,8 +73,9 @@ const StatusMedia: React.FC<IStatusMedia> = ({
blurhash={video.blurhash ?? undefined} blurhash={video.blurhash ?? undefined}
src={video.url} src={video.url}
alt={video.description} alt={video.description}
aspectRatio={Number(video.meta?.original?.aspect)} aspectRatio={video.meta?.original?.aspect}
height={285} height={video?.meta?.original?.height}
width={video?.meta?.original?.width}
visible={showMedia} visible={showMedia}
inline inline
/> />

Wyświetl plik

@ -6,7 +6,7 @@ import { useApi } from 'soapbox/hooks/useApi.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useGetState } from 'soapbox/hooks/useGetState.ts'; import { useGetState } from 'soapbox/hooks/useGetState.ts';
import { filteredArray } from 'soapbox/schemas/utils.ts'; import { filteredArrayAsync } from 'soapbox/schemas/utils.ts';
import { realNumberSchema } from 'soapbox/utils/numbers.tsx'; import { realNumberSchema } from 'soapbox/utils/numbers.tsx';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions.ts'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions.ts';
@ -58,7 +58,7 @@ function useEntities<TEntity extends Entity>(
const next = useListState(path, 'next'); const next = useListState(path, 'next');
const prev = useListState(path, 'prev'); const prev = useListState(path, 'prev');
const fetchPage = async(req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => { const fetchPage = async (req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
// Get `isFetching` state from the store again to prevent race conditions. // Get `isFetching` state from the store again to prevent race conditions.
const isFetching = selectListState(getState(), path, 'fetching'); const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return; if (isFetching) return;
@ -67,7 +67,7 @@ function useEntities<TEntity extends Entity>(
try { try {
const response = await req(); const response = await req();
const json = await response.json(); const json = await response.json();
const entities = filteredArray(schema).parse(json); const entities = await filteredArrayAsync(schema).parseAsync(json);
const parsedCount = realNumberSchema.safeParse(response.headers.get('x-total-count')); const parsedCount = realNumberSchema.safeParse(response.headers.get('x-total-count'));
const totalCount = parsedCount.success ? parsedCount.data : undefined; const totalCount = parsedCount.success ? parsedCount.data : undefined;
const linkHeader = response.headers.get('link'); const linkHeader = response.headers.get('link');

Wyświetl plik

@ -47,7 +47,7 @@ function useEntity<TEntity extends Entity>(
try { try {
const response = await setPromise(entityFn()); const response = await setPromise(entityFn());
const json = await response.json(); const json = await response.json();
const entity = schema.parse(json); const entity = await schema.parseAsync(json);
dispatch(importEntities([entity], entityType)); dispatch(importEntities([entity], entityType));
} catch (e) { } catch (e) {
setError(e); setError(e);

Wyświetl plik

@ -83,14 +83,75 @@ const attachmentSchema = z.discriminatedUnion('type', [
gifvAttachmentSchema, gifvAttachmentSchema,
audioAttachmentSchema, audioAttachmentSchema,
unknownAttachmentSchema, unknownAttachmentSchema,
]).transform((attachment) => { ]).transform(async (attachment) => {
if (!attachment.preview_url) { if (!attachment.preview_url) {
attachment.preview_url = attachment.url; attachment.preview_url = attachment.url;
} }
if (attachment.type === 'image') {
if (!attachment.meta.original) {
try {
const { width, height } = await getImageDimensions(attachment.url);
attachment.meta.original = { width, height, aspect: width / height };
} catch {
// Image metadata is not available
}
}
}
if (attachment.type === 'video') {
if (!attachment.meta.original) {
try {
const { width, height } = await getVideoDimensions(attachment.url);
attachment.meta.original = { width, height, aspect: width / height };
} catch {
// Video metadata is not available
}
}
}
return attachment; return attachment;
}); });
async function getImageDimensions(url: string): Promise<{ width: number; height: number }> {
const response = await fetch(url);
const blob = await response.blob();
return await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(img.src); // Cleanup
};
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
}
async function getVideoDimensions(url: string): Promise<{ width: number; height: number }> {
const response = await fetch(url);
const blob = await response.blob();
return await new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
video.currentTime = 0.1; // Force processing of video frames
};
video.onseeked = () => {
resolve({ width: video.videoWidth, height: video.videoHeight });
URL.revokeObjectURL(video.src);
};
video.onerror = reject;
video.src = URL.createObjectURL(blob);
});
}
type Attachment = z.infer<typeof attachmentSchema>; type Attachment = z.infer<typeof attachmentSchema>;
export { attachmentSchema, type Attachment }; export { attachmentSchema, type Attachment };

Wyświetl plik

@ -13,7 +13,7 @@ import { groupSchema } from './group.ts';
import { mentionSchema } from './mention.ts'; import { mentionSchema } from './mention.ts';
import { pollSchema } from './poll.ts'; import { pollSchema } from './poll.ts';
import { tagSchema } from './tag.ts'; import { tagSchema } from './tag.ts';
import { contentSchema, dateSchema, filteredArray } from './utils.ts'; import { contentSchema, dateSchema, filteredArray, filteredArrayAsync } from './utils.ts';
import type { Resolve } from 'soapbox/utils/types.ts'; import type { Resolve } from 'soapbox/utils/types.ts';
@ -48,7 +48,7 @@ const baseStatusSchema = z.object({
in_reply_to_id: z.string().nullable().catch(null), in_reply_to_id: z.string().nullable().catch(null),
id: z.string(), id: z.string(),
language: z.string().nullable().catch(null), language: z.string().nullable().catch(null),
media_attachments: filteredArray(attachmentSchema), media_attachments: filteredArrayAsync(attachmentSchema),
mentions: filteredArray(mentionSchema), mentions: filteredArray(mentionSchema),
muted: z.coerce.boolean(), muted: z.coerce.boolean(),
pinned: z.coerce.boolean(), pinned: z.coerce.boolean(),

Wyświetl plik

@ -17,6 +17,19 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
)); ));
} }
/** Validates individual items in an async array schema, dropping any that aren't valid. */
function filteredArrayAsync<T extends z.ZodTypeAny>(schema: T) {
return z.any().array().catch([])
.transform(async (arr) => {
const results = await Promise.all(arr.map(async (item) => {
const parsed = await schema.safeParseAsync(item);
return parsed.success ? parsed.data : undefined;
}));
return results.filter((item): item is z.infer<T> => Boolean(item));
});
}
/** Validates the string as an emoji. */ /** Validates the string as an emoji. */
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}]{2}/u.test(v)); const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}]{2}/u.test(v));
@ -42,4 +55,4 @@ function coerceObject<T extends z.ZodRawShape>(shape: T) {
/** Validates a hex color code. */ /** Validates a hex color code. */
const hexColorSchema = z.string().regex(/^#([a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/i); const hexColorSchema = z.string().regex(/^#([a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/i);
export { filteredArray, hexColorSchema, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject }; export { filteredArray, filteredArrayAsync, hexColorSchema, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject };

Wyświetl plik

@ -131,13 +131,13 @@ function buildCSP(env: string | undefined): string {
csp.push( csp.push(
"connect-src 'self' blob: https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*", "connect-src 'self' blob: https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
"img-src 'self' data: blob: https: http://localhost:* http://127.0.0.1:*", "img-src 'self' data: blob: https: http://localhost:* http://127.0.0.1:*",
"media-src 'self' https: http://localhost:* http://127.0.0.1:*", "media-src 'self' blob: https: http://localhost:* http://127.0.0.1:*",
); );
} else { } else {
csp.push( csp.push(
"connect-src 'self' blob: https: wss:", "connect-src 'self' blob: https: wss:",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"media-src 'self' https:", "media-src 'self' blob: https:",
); );
} }