| 1 | import type { SnapShotSource } from "@t3tools/contracts"; |
| 2 | |
| 3 | import type { ComposerFileAttachment } from "../../composerDraftStore"; |
| 4 | import { type ChatFileAttachment, type ChatImageAttachment, isVideoAttachment } from "../../types"; |
| 5 | import type { |
| 6 | AssetCreateUrlResult, |
| 7 | AssetResource, |
| 8 | EnvironmentId, |
| 9 | ScopedThreadRef, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { videoMimeType } from "@t3tools/shared/video"; |
| 12 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; |
| 13 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 14 | import { |
| 15 | squashAtomCommandFailure, |
| 16 | type AtomCommandResult, |
| 17 | } from "@t3tools/client-runtime/state/runtime"; |
| 18 | import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; |
| 19 | import type { MediaActionSource } from "../media/MediaActions"; |
| 20 | import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; |
| 21 | |
| 22 | export interface ExpandedImageItem { |
| 23 | /** A loadable URL, or null when the dialog must mint one from `asset` first. */ |
| 24 | src: string | null; |
| 25 | name: string; |
| 26 | type?: "video"; |
| 27 | source?: SnapShotSource; |
| 28 | autoPlay?: boolean; |
| 29 | /** Authored remote destination to open when embedding fails, never a generated asset URL. */ |
| 30 | originalUrl?: string; |
| 31 | srcFragment?: string; |
| 32 | actionsSource?: MediaActionSource; |
| 33 | } |
| 34 | |
| 35 | export interface ExpandedImagePreview { |
| 36 | images: ExpandedImageItem[]; |
| 37 | index: number; |
| 38 | } |
| 39 | |
| 40 | /** Wraps navigation in either direction, including offsets beyond a complete cycle. */ |
| 41 | export function wrapExpandedImageIndex(index: number, imageCount: number): number { |
| 42 | return imageCount > 0 ? ((index % imageCount) + imageCount) % imageCount : 0; |
| 43 | } |
| 44 | |
| 45 | /** Resolves a chat media reference on its owning environment, without downloading its bytes. */ |
| 46 | export async function resolveMarkdownMediaPreview(input: { |
| 47 | source: string; |
| 48 | resolvedFilePath?: string | undefined; |
| 49 | cwd?: string | undefined; |
| 50 | threadRef?: ScopedThreadRef | undefined; |
| 51 | httpBaseUrl?: string | undefined; |
| 52 | onOpenFile?: ((relativePath: string) => void) | undefined; |
| 53 | createAssetUrl: (input: { |
| 54 | environmentId: EnvironmentId; |
| 55 | input: { resource: AssetResource }; |
| 56 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, unknown>>; |
| 57 | }): Promise<ExpandedImagePreview | null> { |
| 58 | const media = resolveMediaSource(input.source, { |
| 59 | threadId: input.threadRef?.threadId, |
| 60 | workspaceRoot: input.cwd, |
| 61 | resolvedFilePath: input.resolvedFilePath, |
| 62 | }); |
| 63 | if (media === null) return null; |
| 64 | const { kind, name, reference } = media; |
| 65 | const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; |
| 66 | |
| 67 | let src: string; |
| 68 | let asset: MediaActionSource["asset"]; |
| 69 | if (media.access === "direct") { |
| 70 | src = resolveProtocolRelativeMediaUrl(media.uri); |
| 71 | } else { |
| 72 | if (media.access === "unavailable" || !input.threadRef || !input.httpBaseUrl) { |
| 73 | throw new Error("Reconnect to this environment and open the media again."); |
| 74 | } |
| 75 | asset = { environmentId: input.threadRef.environmentId, resource: media.resource }; |
| 76 | const result = await input.createAssetUrl({ |
| 77 | environmentId: asset.environmentId, |
| 78 | input: { resource: asset.resource }, |
| 79 | }); |
| 80 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 81 | const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); |
| 82 | if (assetUrl === null) throw new Error("The environment returned an invalid media URL."); |
| 83 | src = assetUrl + media.srcFragment; |
| 84 | } |
| 85 | return { |
| 86 | images: [ |
| 87 | { |
| 88 | src, |
| 89 | name, |
| 90 | ...(kind === "video" ? { type: "video", autoPlay: false } : {}), |
| 91 | ...(media.access === "direct" && resolveExternalWebLinkHost(media.uri) !== null |
| 92 | ? { originalUrl: media.uri } |
| 93 | : {}), |
| 94 | ...(media.srcFragment ? { srcFragment: media.srcFragment } : {}), |
| 95 | actionsSource: { |
| 96 | kind, |
| 97 | name, |
| 98 | src, |
| 99 | ...(reference ? { reference } : {}), |
| 100 | ...(asset ? { asset } : {}), |
| 101 | ...(relativePath && input.onOpenFile |
| 102 | ? { onOpenFile: () => input.onOpenFile?.(relativePath) } |
| 103 | : {}), |
| 104 | }, |
| 105 | }, |
| 106 | ], |
| 107 | index: 0, |
| 108 | }; |
| 109 | } |
| 110 | |
| 111 | export function buildAttachmentVideoAsset( |
| 112 | environmentId: EnvironmentId, |
| 113 | attachment: ChatFileAttachment, |
| 114 | ): NonNullable<MediaActionSource["asset"]> { |
| 115 | return { |
| 116 | environmentId, |
| 117 | resource: { |
| 118 | _tag: "attachment" as const, |
| 119 | attachmentId: attachment.id, |
| 120 | fileName: attachment.name, |
| 121 | mimeType: videoMimeType(attachment) ?? attachment.mimeType, |
| 122 | }, |
| 123 | }; |
| 124 | } |
| 125 | |
| 126 | /** Opens a persisted video through the same signed-asset dialog used by message media. */ |
| 127 | export function buildAttachmentVideoPreview( |
| 128 | environmentId: EnvironmentId, |
| 129 | attachment: ChatFileAttachment, |
| 130 | ): ExpandedImagePreview | null { |
| 131 | if (!isVideoAttachment(attachment)) return null; |
| 132 | const src = attachment.previewUrl ?? null; |
| 133 | const asset = |
| 134 | attachment.downloadable === false |
| 135 | ? undefined |
| 136 | : buildAttachmentVideoAsset(environmentId, attachment); |
| 137 | if (src === null && asset === undefined) return null; |
| 138 | return { |
| 139 | images: [ |
| 140 | { |
| 141 | src, |
| 142 | name: attachment.name, |
| 143 | type: "video", |
| 144 | actionsSource: { |
| 145 | kind: "video", |
| 146 | name: attachment.name, |
| 147 | src, |
| 148 | ...(asset ? { asset } : {}), |
| 149 | }, |
| 150 | }, |
| 151 | ], |
| 152 | index: 0, |
| 153 | }; |
| 154 | } |
| 155 | |
| 156 | export function expandedImageKey(preview: ExpandedImagePreview): string { |
| 157 | const item = preview.images[preview.index]; |
| 158 | const asset = item?.actionsSource?.asset; |
| 159 | return `${item?.src ?? (asset ? JSON.stringify([asset.environmentId, asset.resource]) : "image")}:${preview.index}`; |
| 160 | } |
| 161 | |
| 162 | export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { |
| 163 | const url = URL.createObjectURL(file); |
| 164 | video.src = url; |
| 165 | return () => URL.revokeObjectURL(url); |
| 166 | } |
| 167 | |
| 168 | export function buildExpandedImagePreview( |
| 169 | images: ReadonlyArray<ChatImageAttachment | ComposerFileAttachment>, |
| 170 | selectedImageId: string, |
| 171 | ): ExpandedImagePreview | null { |
| 172 | const selected = images.find((image) => image.id === selectedImageId); |
| 173 | if (selected?.type === "file" && selected.file && isVideoAttachment(selected)) { |
| 174 | return { |
| 175 | images: [{ src: URL.createObjectURL(selected.file), name: selected.name, type: "video" }], |
| 176 | index: 0, |
| 177 | }; |
| 178 | } |
| 179 | const previewableImages = images.flatMap((image) => |
| 180 | image.type === "image" && image.previewUrl |
| 181 | ? [{ id: image.id, src: image.previewUrl, name: image.name, source: image.source }] |
| 182 | : [], |
| 183 | ); |
| 184 | if (previewableImages.length === 0) { |
| 185 | return null; |
| 186 | } |
| 187 | const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); |
| 188 | if (selectedIndex < 0) { |
| 189 | return null; |
| 190 | } |
| 191 | return { |
| 192 | images: previewableImages.map((image) => ({ |
| 193 | src: image.src, |
| 194 | name: image.name, |
| 195 | ...(image.source?.kind === "snap-shot" ? { source: image.source } : {}), |
| 196 | })), |
| 197 | index: selectedIndex, |
| 198 | }; |
| 199 | } |