Skip to content

Commit f85c8b4

Browse files
committed
feat: add video output support
1 parent 61101e5 commit f85c8b4

File tree

5 files changed

+291
-1
lines changed

5 files changed

+291
-1
lines changed

playgrounds/app/app.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@ import { defineConfig } from '@solidjs/start/config'
22

33
export default defineConfig({
44
ssr: false,
5+
vite: {
6+
optimizeDeps: { exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'] },
7+
},
58
})

playgrounds/app/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
},
1717
"dependencies": {
1818
"@corvu/resizable": "^0.2.3",
19+
"@ffmpeg/core": "^0.12.6",
20+
"@ffmpeg/ffmpeg": "^0.12.10",
21+
"@ffmpeg/util": "^0.12.1",
1922
"@fontsource/bungee-inline": "^5.1.0",
2023
"@fontsource/roboto": "^5.1.0",
2124
"@kobalte/core": "^0.13.7",
@@ -31,6 +34,7 @@
3134
"diff-match-patch-es": "^0.1.0",
3235
"dotenv": "^16.4.5",
3336
"drizzle-orm": "^0.35.3",
37+
"idb": "^8.0.0",
3438
"jsonwebtoken": "^9.0.2",
3539
"modern-gif": "^2.0.3",
3640
"nanoid": "^5.0.7",

playgrounds/app/src/components/Editor.tsx

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,20 @@ import { toast } from 'solid-sonner'
5555
import { Separator } from './ui/separator'
5656
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
5757
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion'
58+
import {
59+
DropdownMenu,
60+
DropdownMenuContent,
61+
DropdownMenuItem,
62+
DropdownMenuTrigger,
63+
} from './ui/dropdown-menu'
5864
import { ShikiCodeBlock } from './ShikiCodeBlock'
5965
import { SetStoreFunction } from 'solid-js/store'
66+
import { FFmpeg } from '@ffmpeg/ffmpeg'
67+
import { fetchFile, toBlobURL } from '@ffmpeg/util'
68+
import coreURL from '@ffmpeg/core?url'
69+
import wasmURL from '@ffmpeg/core/wasm?url'
70+
import { openDB } from 'idb'
71+
import { ProgressCircle } from './ui/progress-circle'
6072

6173
const animationSeconds = 1
6274
const animationFPS = 30
@@ -109,6 +121,13 @@ export default function Editor(props: EditorProps) {
109121
const [isSaving, setIsSaving] = createSignal(false)
110122
const [highlighter, setHighlighter] = createSignal<HighlighterGeneric<any, any> | undefined>()
111123

124+
const [isShowingFfmpegDialog, setIsShowingFfmpegDialog] = createSignal(false)
125+
const [ffmpegLoaded, setFfmpegLoaded] = createSignal(false)
126+
const [isDownloadingFfmpeg, setIsDownloadingFfmpeg] = createSignal(false)
127+
const [isGeneratingVideo, setIsGeneratingVideo] = createSignal(false)
128+
const [videoProgress, setVideoProgress] = createSignal(0)
129+
const ffmpeg = new FFmpeg()
130+
112131
createEffect(() => {
113132
createHighlighter({
114133
themes: [props.snippetSettings.theme],
@@ -129,6 +148,8 @@ export default function Editor(props: EditorProps) {
129148
props.snippetSettings.codeLeft !== '' &&
130149
props.snippetSettings.codeRight !== '' &&
131150
!isResizing() &&
151+
!isShowingGifDialog() &&
152+
!isShowingFfmpegDialog() &&
132153
isLooping()
133154
) {
134155
if (toggled()) {
@@ -957,16 +978,130 @@ export default function Editor(props: EditorProps) {
957978
link.click()
958979
}}
959980
>
960-
Download
981+
Download GIF
961982
</Button>
983+
984+
<Show
985+
when={ffmpegLoaded()}
986+
fallback={
987+
<Button
988+
onClick={() => {
989+
setIsShowingFfmpegDialog(true)
990+
}}
991+
>
992+
Enable Video
993+
</Button>
994+
}
995+
>
996+
<Button
997+
disabled={isGeneratingVideo()}
998+
onClick={async () => {
999+
setIsGeneratingVideo(true)
1000+
setVideoProgress(0)
1001+
await ffmpeg.writeFile('input.gif', dataURItoUInt8Array(gifDataUrl()))
1002+
await ffmpeg.exec(['-i', 'input.gif', 'output.mp4'])
1003+
const data = await ffmpeg.readFile('output.mp4')
1004+
const blob = new Blob([data], { type: 'video/mp4' })
1005+
const filename = 'giffium.mp4'
1006+
const link = document.createElement('a')
1007+
link.href = URL.createObjectURL(blob)
1008+
link.download = filename
1009+
link.click()
1010+
setIsGeneratingVideo(false)
1011+
}}
1012+
>
1013+
<Show when={isGeneratingVideo()} fallback="Download MP4">
1014+
<span class="flex flex-row gap-1 items-center justify-center">
1015+
<span>Generating...</span>
1016+
<ProgressCircle
1017+
radius={12}
1018+
value={videoProgress()}
1019+
strokeWidth={4}
1020+
color="green"
1021+
class="border-green-500"
1022+
/>
1023+
</span>
1024+
</Show>
1025+
</Button>
1026+
</Show>
9621027
</Show>
9631028
</DialogFooter>
9641029
</DialogContent>
9651030
</Dialog>
1031+
<Dialog open={isShowingFfmpegDialog()} onOpenChange={setIsShowingFfmpegDialog} modal>
1032+
<DialogContent>
1033+
<Show when={!isDownloadingFfmpeg()} fallback={<p>Downloading...</p>}>
1034+
<p class="">To create video, must download ffmpeg.wasm. It is approximately 30MB.</p>
1035+
</Show>
1036+
<DialogFooter>
1037+
<Button
1038+
disabled={isDownloadingFfmpeg()}
1039+
onClick={() => setIsShowingFfmpegDialog(false)}
1040+
>
1041+
Cancel
1042+
</Button>
1043+
<Button
1044+
disabled={isDownloadingFfmpeg()}
1045+
onClick={async () => {
1046+
setIsDownloadingFfmpeg(true)
1047+
const baseURL = 'https://unpkg.com/@ffmpeg/[email protected]/dist/esm'
1048+
ffmpeg.on('log', ({ message }) => {
1049+
console.log(message)
1050+
})
1051+
ffmpeg.on('progress', ({ progress, time }) => {
1052+
setVideoProgress(Math.round(progress * 100))
1053+
})
1054+
try {
1055+
// toBlobURL is used to bypass CORS issue, urls with the same
1056+
// domain can be used directly.
1057+
await ffmpeg.load({
1058+
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
1059+
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
1060+
// We use the unpkg to reduce bandwidth usage to netlify
1061+
// coreURL,
1062+
// wasmURL,
1063+
})
1064+
setFfmpegLoaded(true)
1065+
} catch (e) {
1066+
console.error(e)
1067+
setFfmpegLoaded(false)
1068+
// TODO: show error
1069+
}
1070+
setIsDownloadingFfmpeg(false)
1071+
setIsShowingFfmpegDialog(false)
1072+
}}
1073+
>
1074+
Download
1075+
</Button>
1076+
</DialogFooter>
1077+
</DialogContent>
1078+
</Dialog>
9661079
</>
9671080
)
9681081
}
9691082

1083+
function dataURItoUInt8Array(dataURI: string) {
1084+
// convert base64 to raw binary data held in a string
1085+
// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
1086+
var byteString = atob(dataURI.split(',')[1])
1087+
1088+
// separate out the mime component
1089+
var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
1090+
1091+
// write the bytes of the string to an ArrayBuffer
1092+
var ab = new ArrayBuffer(byteString.length)
1093+
1094+
// create a view into the buffer
1095+
var ia = new Uint8Array(ab)
1096+
1097+
// set the bytes of the buffer to the correct values
1098+
for (var i = 0; i < byteString.length; i++) {
1099+
ia[i] = byteString.charCodeAt(i)
1100+
}
1101+
1102+
return ia
1103+
}
1104+
9701105
function dataURItoBlob(dataURI: string) {
9711106
// convert base64 to raw binary data held in a string
9721107
// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
@@ -1112,3 +1247,13 @@ async function createAnimationFrame(
11121247

11131248
return ctx.getImageData(0, 0, canvas.width, canvas.height)
11141249
}
1250+
1251+
// Not actually necessary since the browser will cache the wasm file
1252+
async function wrappedToBlobURL(url: string, mimeType: string) {
1253+
const storeName = 'ffmpegCache'
1254+
const db = await openDB(storeName, 1, {})
1255+
1256+
return db.get(storeName, url).catch(() => {
1257+
return toBlobURL(url, mimeType)
1258+
})
1259+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { Component, ComponentProps } from 'solid-js'
2+
import { mergeProps, splitProps } from 'solid-js'
3+
4+
import { cn } from '~/lib/utils'
5+
6+
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
7+
8+
const sizes: Record<Size, { radius: number; strokeWidth: number }> = {
9+
xs: { radius: 15, strokeWidth: 3 },
10+
sm: { radius: 19, strokeWidth: 4 },
11+
md: { radius: 32, strokeWidth: 6 },
12+
lg: { radius: 52, strokeWidth: 8 },
13+
xl: { radius: 80, strokeWidth: 10 },
14+
}
15+
16+
type ProgressCircleProps = ComponentProps<'div'> & {
17+
value?: number
18+
size?: Size
19+
radius?: number
20+
strokeWidth?: number
21+
showAnimation?: boolean
22+
}
23+
24+
const ProgressCircle: Component<ProgressCircleProps> = rawProps => {
25+
const props = mergeProps({ size: 'md' as Size, showAnimation: true }, rawProps)
26+
const [local, others] = splitProps(props, [
27+
'class',
28+
'children',
29+
'value',
30+
'size',
31+
'radius',
32+
'strokeWidth',
33+
'showAnimation',
34+
])
35+
36+
const value = () => getLimitedValue(local.value)
37+
const radius = () => local.radius ?? sizes[local.size].radius
38+
const strokeWidth = () => local.strokeWidth ?? sizes[local.size].strokeWidth
39+
const normalizedRadius = () => radius() - strokeWidth() / 2
40+
const circumference = () => normalizedRadius() * 2 * Math.PI
41+
const strokeDashoffset = () => (value() / 100) * circumference()
42+
const offset = () => circumference() - strokeDashoffset()
43+
44+
return (
45+
<div class={cn('flex flex-col items-center justify-center', local.class)} {...others}>
46+
<svg
47+
width={radius() * 2}
48+
height={radius() * 2}
49+
viewBox={`0 0 ${radius() * 2} ${radius() * 2}`}
50+
class="-rotate-90"
51+
>
52+
<circle
53+
r={normalizedRadius()}
54+
cx={radius()}
55+
cy={radius()}
56+
stroke-width={strokeWidth()}
57+
fill="transparent"
58+
stroke=""
59+
stroke-linecap="round"
60+
class={cn('stroke-secondary transition-colors ease-linear')}
61+
/>
62+
{value() >= 0 ? (
63+
<circle
64+
r={normalizedRadius()}
65+
cx={radius()}
66+
cy={radius()}
67+
stroke-width={strokeWidth()}
68+
stroke-dasharray={circumference() + ' ' + circumference()}
69+
stroke-dashoffset={offset()}
70+
fill="transparent"
71+
stroke=""
72+
stroke-linecap="round"
73+
class={cn(
74+
'stroke-green-500 transition-colors ease-linear',
75+
local.showAnimation ? 'transition-all duration-300 ease-in-out' : '',
76+
)}
77+
/>
78+
) : null}
79+
</svg>
80+
<div class={cn('absolute flex')}>{local.children}</div>
81+
</div>
82+
)
83+
}
84+
85+
function getLimitedValue(input: number | undefined) {
86+
if (input === undefined) {
87+
return 0
88+
} else if (input > 100) {
89+
return 100
90+
}
91+
return input
92+
}
93+
94+
export { ProgressCircle }

0 commit comments

Comments
 (0)