personal web client for Bluesky
typescript solidjs bluesky atcute

feat: gif to video conversion

mary.my.id e4736ca4 4b12ecfa

verified
+4 -1
package.json
··· 24 24 "@mary/exif-rm": "npm:@jsr/mary__exif-rm@^0.2.1", 25 25 "@mary/solid-freeze": "npm:@externdefs/solid-freeze@^0.1.1", 26 26 "@mary/solid-query": "npm:@externdefs/solid-query@^0.1.5", 27 + "comlink": "^4.4.1", 27 28 "hls.js": "^1.5.17", 28 29 "idb": "^8.0.0", 29 30 "nanoid": "^5.0.7", 30 31 "solid-floating-ui": "~0.2.1", 31 - "solid-js": "^1.9.2" 32 + "solid-js": "^1.9.2", 33 + "webm-muxer": "^5.0.2" 32 34 }, 33 35 "devDependencies": { 34 36 "@trivago/prettier-plugin-sort-imports": "^4.3.0", 35 37 "@types/dom-close-watcher": "^1.0.0", 38 + "@types/dom-webcodecs": "^0.1.13", 36 39 "autoprefixer": "^10.4.20", 37 40 "babel-plugin-transform-typescript-const-enums": "^0.1.0", 38 41 "prettier": "^3.3.3",
+32
pnpm-lock.yaml
··· 76 76 '@mary/solid-query': 77 77 specifier: npm:@externdefs/solid-query@^0.1.5 78 78 version: '@externdefs/solid-query@0.1.5(solid-js@1.9.2(patch_hash=5rodyfcb76rtbo26dwlsojy7jy))' 79 + comlink: 80 + specifier: ^4.4.1 81 + version: 4.4.1 79 82 hls.js: 80 83 specifier: ^1.5.17 81 84 version: 1.5.17 ··· 91 94 solid-js: 92 95 specifier: ^1.9.2 93 96 version: 1.9.2(patch_hash=5rodyfcb76rtbo26dwlsojy7jy) 97 + webm-muxer: 98 + specifier: ^5.0.2 99 + version: 5.0.2 94 100 devDependencies: 95 101 '@trivago/prettier-plugin-sort-imports': 96 102 specifier: ^4.3.0 ··· 98 104 '@types/dom-close-watcher': 99 105 specifier: ^1.0.0 100 106 version: 1.0.0 107 + '@types/dom-webcodecs': 108 + specifier: ^0.1.13 109 + version: 0.1.13 101 110 autoprefixer: 102 111 specifier: ^10.4.20 103 112 version: 10.4.20(postcss@8.4.47) ··· 1279 1288 '@types/dom-close-watcher@1.0.0': 1280 1289 resolution: {integrity: sha512-7pL0By56sVVGMSJ3HdSY+u08Id0ljStCaf1VnGFxwfpuNdA0HMz0sl2J24eSi9M6ptl9ySkVK35jF75Fn8trUg==} 1281 1290 1291 + '@types/dom-webcodecs@0.1.13': 1292 + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} 1293 + 1282 1294 '@types/estree@0.0.39': 1283 1295 resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} 1284 1296 ··· 1296 1308 1297 1309 '@types/trusted-types@2.0.7': 1298 1310 resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 1311 + 1312 + '@types/wicg-file-system-access@2020.9.8': 1313 + resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==} 1299 1314 1300 1315 acorn-walk@8.3.4: 1301 1316 resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} ··· 1469 1484 color-name@1.1.4: 1470 1485 resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 1471 1486 1487 + comlink@4.4.1: 1488 + resolution: {integrity: sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q==} 1489 + 1472 1490 commander@2.20.3: 1473 1491 resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} 1474 1492 ··· 2698 2716 2699 2717 webidl-conversions@4.0.2: 2700 2718 resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} 2719 + 2720 + webm-muxer@5.0.2: 2721 + resolution: {integrity: sha512-/1m0ZGOcx8TvEmqY+U7GromXTKtto4pi5ou+ZvaQXEjb+G/v1cln2ZbKK3T4fpWhLBYjKBeYWza/y1Nxccm5pg==} 2701 2722 2702 2723 whatwg-url@7.1.0: 2703 2724 resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} ··· 3980 4001 3981 4002 '@types/dom-close-watcher@1.0.0': {} 3982 4003 4004 + '@types/dom-webcodecs@0.1.13': {} 4005 + 3983 4006 '@types/estree@0.0.39': {} 3984 4007 3985 4008 '@types/estree@1.0.6': {} ··· 3996 4019 3997 4020 '@types/trusted-types@2.0.7': {} 3998 4021 4022 + '@types/wicg-file-system-access@2020.9.8': {} 4023 + 3999 4024 acorn-walk@8.3.4: 4000 4025 dependencies: 4001 4026 acorn: 8.13.0 ··· 4199 4224 color-name@1.1.3: {} 4200 4225 4201 4226 color-name@1.1.4: {} 4227 + 4228 + comlink@4.4.1: {} 4202 4229 4203 4230 commander@2.20.3: {} 4204 4231 ··· 5429 5456 vite: 6.0.0-beta.1(patch_hash=wl54gd7ndnry4uf2hjz6glag5u)(@types/node@22.7.6)(terser@5.36.0) 5430 5457 5431 5458 webidl-conversions@4.0.2: {} 5459 + 5460 + webm-muxer@5.0.2: 5461 + dependencies: 5462 + '@types/dom-webcodecs': 0.1.13 5463 + '@types/wicg-file-system-access': 2020.9.8 5432 5464 5433 5465 whatwg-url@7.1.0: 5434 5466 dependencies:
+18
src/components/composer/composer-dialog.tsx
··· 61 61 import ComposerInput from './composer-input'; 62 62 import ComposerReplyContext from './composer-reply-context'; 63 63 import ContentWarningMenu from './dialogs/content-warning-menu'; 64 + import GifConversionPromptLazy from './dialogs/gif-conversion-prompt-lazy'; 64 65 import LanguageSelectDialogLazy from './dialogs/language-select-dialog-lazy'; 65 66 import ThreadgateMenu from './dialogs/threadgate-menu'; 66 67 import DraftListDialogLazy from './drafts/draft-list-dialog-lazy'; ··· 665 666 } 666 667 667 668 post.embed.media = next; 669 + return; 670 + } 671 + 672 + const gif = blobs.find((blob) => blob.type === 'image/gif'); 673 + if (gif) { 674 + if (!post.embed.media) { 675 + if (!VideoEncoder || !ImageDecoder) { 676 + onError(`Your browser doesn't support converting GIF into video`); 677 + return; 678 + } 679 + 680 + onError(); 681 + openModal(() => ( 682 + <GifConversionPromptLazy blob={gif} onSuccess={(video) => addImagesOrVideo([video])} /> 683 + )); 684 + } 685 + 668 686 return; 669 687 } 670 688
+5
src/components/composer/dialogs/gif-conversion-prompt-lazy.tsx
··· 1 + import { lazy } from 'solid-js'; 2 + 3 + const GifConversionPromptLazy = lazy(() => import('./gif-conversion-prompt')); 4 + 5 + export default GifConversionPromptLazy;
+77
src/components/composer/dialogs/gif-conversion-prompt.tsx
··· 1 + import { wrap } from 'comlink'; 2 + import { Match, Switch, createResource } from 'solid-js'; 3 + 4 + import { useModalContext } from '~/globals/modals'; 5 + 6 + import { makeAbortable } from '~/lib/hooks/abortable'; 7 + 8 + import CircularProgressView from '~/components/circular-progress-view'; 9 + import * as Prompt from '~/components/prompt'; 10 + 11 + import type { GifWorkerApi } from '../workers/gif-conversion'; 12 + 13 + export interface GifConversionPromptProps { 14 + blob: Blob; 15 + onSuccess: (video: Blob) => void; 16 + } 17 + 18 + const GifConversionPrompt = ({ blob, onSuccess }: GifConversionPromptProps) => { 19 + const { close } = useModalContext(); 20 + 21 + const [getAbortSignal] = makeAbortable(); 22 + const [resource] = createResource(async () => { 23 + const signal = getAbortSignal(); 24 + 25 + let video: Blob; 26 + { 27 + const workerUrl = new URL('../workers/gif-conversion', import.meta.url); 28 + const worker = new Worker(workerUrl, { type: 'module' }); 29 + 30 + try { 31 + const api = wrap<GifWorkerApi>(worker); 32 + video = await api.transform(blob); 33 + } finally { 34 + worker.terminate(); 35 + } 36 + } 37 + 38 + signal.throwIfAborted(); 39 + 40 + close(); 41 + onSuccess(video); 42 + }); 43 + 44 + return ( 45 + <Prompt.Container> 46 + <Switch> 47 + <Match when={resource.error}> 48 + {(err) => ( 49 + <> 50 + <Prompt.Title>Failed to convert GIF</Prompt.Title> 51 + <p class="text-pretty text-de text-error">{'' + err()}</p> 52 + 53 + <Prompt.Actions> 54 + <Prompt.Action variant="primary">Close</Prompt.Action> 55 + </Prompt.Actions> 56 + </> 57 + )} 58 + </Match> 59 + 60 + <Match when> 61 + <Prompt.Title>Converting GIF</Prompt.Title> 62 + <Prompt.Description>This might take a bit</Prompt.Description> 63 + 64 + <div class="mt-6"> 65 + <CircularProgressView /> 66 + </div> 67 + 68 + <Prompt.Actions> 69 + <Prompt.Action>Cancel</Prompt.Action> 70 + </Prompt.Actions> 71 + </Match> 72 + </Switch> 73 + </Prompt.Container> 74 + ); 75 + }; 76 + 77 + export default GifConversionPrompt;
+52
src/components/composer/workers/gif-conversion.ts
··· 1 + import { expose } from 'comlink'; 2 + import { ArrayBufferTarget, Muxer } from 'webm-muxer'; 3 + 4 + export type GifWorkerApi = typeof api; 5 + const api = { 6 + async transform(blob: Blob) { 7 + const decoder = new ImageDecoder({ type: 'image/gif', data: await blob.arrayBuffer() }); 8 + await decoder.tracks.ready; 9 + 10 + const frameCount = decoder.tracks.selectedTrack!.frameCount; 11 + 12 + let muxer: Muxer<ArrayBufferTarget>; 13 + let encoder: VideoEncoder | undefined; 14 + 15 + if (frameCount === 0) { 16 + throw new Error(`GIF has no frames`); 17 + } 18 + 19 + for (let idx = 0, configured = false; idx < frameCount; idx++) { 20 + const { image } = await decoder.decode({ frameIndex: idx }); 21 + 22 + if (!configured) { 23 + const width = image.displayWidth; 24 + const height = image.displayHeight; 25 + 26 + configured = true; 27 + 28 + muxer = new Muxer({ 29 + target: new ArrayBufferTarget(), 30 + video: { codec: 'V_VP9', width, height }, 31 + }); 32 + 33 + encoder = new VideoEncoder({ 34 + output: (chunk) => muxer.addVideoChunk(chunk), 35 + error: (err) => console.error(err), 36 + }); 37 + 38 + encoder.configure({ codec: 'vp09.00.10.08', width, height }); 39 + } 40 + 41 + encoder!.encode(image); 42 + } 43 + 44 + await encoder!.flush(); 45 + muxer!.finalize(); 46 + 47 + const buffer = muxer!.target.buffer; 48 + return new Blob([buffer], { type: 'video/webm' }); 49 + }, 50 + }; 51 + 52 + expose(api);
+1 -1
tsconfig.json
··· 2 2 "compilerOptions": { 3 3 "target": "ESNext", 4 4 "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 - "types": ["dom-close-watcher"], 5 + "types": ["dom-close-watcher", "dom-webcodecs"], 6 6 "skipLibCheck": true, 7 7 8 8 "module": "ESNext",