@@ -55,8 +55,20 @@ import { toast } from 'solid-sonner'
5555import { Separator } from './ui/separator'
5656import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from './ui/select'
5757import { Accordion , AccordionContent , AccordionItem , AccordionTrigger } from './ui/accordion'
58+ import {
59+ DropdownMenu ,
60+ DropdownMenuContent ,
61+ DropdownMenuItem ,
62+ DropdownMenuTrigger ,
63+ } from './ui/dropdown-menu'
5864import { ShikiCodeBlock } from './ShikiCodeBlock'
5965import { 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
6173const animationSeconds = 1
6274const 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+
9701105function 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+ }
0 commit comments