+4
-1
package.json
+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
+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
+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
+5
src/components/composer/dialogs/gif-conversion-prompt-lazy.tsx
+77
src/components/composer/dialogs/gif-conversion-prompt.tsx
+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
+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);