Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

video to gif project #39

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
},
"editor.formatOnSave": true
}
5 changes: 5 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@ import { defineConfig } from "@solidjs/start/config";

export default defineConfig({
middleware: "./src/middleware.ts",
vite: {
optimizeDeps: {
exclude: ["@ffmpeg/ffmpeg"],
},
},
});
Binary file modified bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"version": "vinxi version"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@fontsource-variable/space-grotesk": "^5.0.18",
"@kobalte/core": "^0.13.4",
"@lucia-auth/adapter-drizzle": "^1.0.7",
Expand All @@ -20,6 +22,7 @@
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.14.1",
"@solidjs/start": "^1.0.6",
"@soorria/solid-dropzone": "^1.0.1",
"@t3-oss/env-core": "^0.11.0",
"@tanstack/solid-query": "^5.51.15",
"@tanstack/solid-query-devtools": "^5.51.15",
Expand Down
157 changes: 157 additions & 0 deletions src/components/projects/video-to-gif.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { createSignal, Match, onMount, Show, Switch } from "solid-js";
import { useFFmpeg } from "~/lib/hooks/ffmpeg";
import { Button } from "../ui/button";
import { LoaderCircle, Video } from "lucide-solid";
import { Progress } from "~/components/ui/progress";
import { createDropzone } from "@soorria/solid-dropzone";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../ui/card";
import { createStore } from "solid-js/store";
import { A } from "@solidjs/router";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";

const [file, setFile] = createSignal<File | undefined | null>();

function Dropzone() {
const onDrop = (acceptedFiles: File[]) => {
if (acceptedFiles.length > 1) {
console.warn(
"[dropzone] there was more than 1 file (somehow), only using the first one",
);
}

const file = acceptedFiles[0];

setFile(file);
};

const dropzone = createDropzone({ accept: "video/mp4", onDrop, maxFiles: 1 });

return (
<div
class="py-8 cursor-pointer outline rounded-md outline-6 dark:outline-gray-600 w-48 h-32 flex justify-center items-center"
{...dropzone.getRootProps()}
>
<input {...dropzone.getInputProps()} />
{dropzone.isDragActive ? (
<p>drop here</p>
) : (
<div class="text-center">
<p>click here to upload</p>
<p>you can also drag and drop</p>
</div>
)}
</div>
);
}

export default function VideoGifConverter() {
const ffmpeg = useFFmpeg();
const [result, setResult] = createStore<{
url: string | undefined | null;
size: string | undefined | null;
}>({
url: null,
size: null,
});

async function reset() {
await ffmpeg.reset();
setFile(() => null);
setResult(() => ({
size: null,
url: null,
}));
}

onMount(async () => {
console.log("new mount, resetting ffmpeg");
await reset();
});

return (
<>
<Show when={ffmpeg.convertStatus() === "idle"}>
<Switch>
<Match when={ffmpeg.status() === "default"}>
<Card>
<CardHeader>
<CardTitle>hi, you need to download ffmpeg</CardTitle>
<CardDescription>file size: ~20mb</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => ffmpeg.load()}>
load ffmpeg (~20mb)
</Button>
</CardContent>
</Card>
</Match>
<Match when={ffmpeg.status() === "loading"}>
<Button disabled>
<LoaderCircle class="h-6 w-6 animate-spin" /> downloading ffmpeg
</Button>
</Match>
<Match when={ffmpeg.status() === "loaded"}>
<Dropzone />
<br />
<Alert class="w-fit pt-4" variant={"destructive"}>
<AlertTitle>warning</AlertTitle>
<AlertDescription>
the resulting file will be a lot larger than the source video
</AlertDescription>
</Alert>
<Show when={file() && ffmpeg.convertStatus() === "idle"}>
{/* biome-ignore lint/a11y/useMediaCaption: it is an user uploaded file */}
<video
class="rounded-md"
controls
width="250"
/* biome-ignore lint/style/noNonNullAssertion: it is safe */
src={URL.createObjectURL(file()!)}
/>
<Button
onClick={async () =>
/* biome-ignore lint/style/noNonNullAssertion: it is safe */
setResult(await ffmpeg.convertToGif(file()!))
}
>
convert
</Button>
</Show>

<Show when={result.url}>
<p>result:</p>
<img
class="rounded-md"
width="500"
/* biome-ignore lint/style/noNonNullAssertion: it is safe */
src={result.url!}
alt="result"
/>
<p>file size: {result.size}</p>

{/* biome-ignore lint/style/noNonNullAssertion: it exists */}
<a href={result.url!} download="result.gif">
<Button variant={"link"}>download</Button>
</a>

<Button onClick={() => reset()}>reset</Button>
</Show>
</Match>
</Switch>
</Show>

<Show when={ffmpeg.progress() !== null && ffmpeg.progress() !== 100}>
<div class="w-fit gap-2 flex flex-row">
<LoaderCircle class="animate-spin h-6 w-6" />
<p>{ffmpeg.progress()}%</p>
</div>
</Show>
</>
);
}
36 changes: 36 additions & 0 deletions src/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cn } from "~/lib/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ProgressRootProps } from "@kobalte/core/progress";
import { Progress as ProgressPrimitive } from "@kobalte/core/progress";
import type { ParentProps, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";

export const ProgressLabel = ProgressPrimitive.Label;
export const ProgressValueLabel = ProgressPrimitive.ValueLabel;

type progressProps<T extends ValidComponent = "div"> = ParentProps<
ProgressRootProps<T> & {
class?: string;
}
>;

export const Progress = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, progressProps<T>>,
) => {
const [local, rest] = splitProps(props as progressProps, [
"class",
"children",
]);

return (
<ProgressPrimitive
class={cn("flex w-full flex-col gap-2", local.class)}
{...rest}
>
{local.children}
<ProgressPrimitive.Track class="h-2 overflow-hidden rounded-full bg-primary/20">
<ProgressPrimitive.Fill class="h-full w-[--kb-progress-fill-width] bg-primary transition-all duration-500 ease-linear data-[progress=complete]:bg-primary" />
</ProgressPrimitive.Track>
</ProgressPrimitive>
);
};
98 changes: 98 additions & 0 deletions src/lib/hooks/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { createSignal } from "solid-js";
import type { FFmpeg } from "@ffmpeg/ffmpeg";

type Status = "loaded" | "loading" | "default";
type ConvertStatus = "converting" | "idle";

export function bytesToSize(bytes: number): string {
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "n/a";
const i: number = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
);
if (i === 0) return `${bytes} ${sizes[i]}`;
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`;
}

export const useFFmpeg = () => {
const [status, setStatus] = createSignal<Status>("default");
const [convertStatus, setConvertStatus] = createSignal<ConvertStatus>("idle");
const [progress, setProgress] = createSignal<number | null>(null);

let ffmpeg: FFmpeg;

const reset = async () => {
setStatus(() => (ffmpeg ? "loaded" : "default"));
setProgress(() => null);
setConvertStatus(() => "idle");
};

const load = async () => {
const { toBlobURL } = await import("@ffmpeg/util");
const { FFmpeg } = await import("@ffmpeg/ffmpeg");

if (status() !== "default") {
console.warn("attempted to load ffmpeg again");
return;
}

if (!ffmpeg) {
ffmpeg = new FFmpeg();
}

ffmpeg.on("log", (msg) => {
console.log(msg.message);
});

ffmpeg.on("progress", ({ progress, time }) => {
setProgress(() => Math.floor(progress * 100));
});

setStatus(() => "loading");
const baseURL = "https://unpkg.com/@ffmpeg/[email protected]/dist/esm";
await ffmpeg
.load({
coreURL: await toBlobURL(
`${baseURL}/ffmpeg-core.js`,
"text/javascript",
),
wasmURL: await toBlobURL(
`${baseURL}/ffmpeg-core.wasm`,
"application/wasm",
),
})
.then(() => setStatus(() => "loaded"));
};

const convertToGif = async (file: File) => {
setConvertStatus(() => "converting");
const { fetchFile } = await import("@ffmpeg/util");
await ffmpeg?.writeFile("input.mp4", await fetchFile(file));

await ffmpeg?.exec(["-i", "input.mp4", "-f", "gif", "out.gif"]);

const data = (await ffmpeg?.readFile("out.gif")) as unknown as {
buffer: Buffer;
};

const blob = new Blob([data.buffer], { type: "image/gif" });

const url = URL.createObjectURL(blob);

setConvertStatus(() => "idle");

return {
url: url,
size: bytesToSize(blob.size),
};
};

return {
status,
load,
convertToGif,
convertStatus,
reset,
progress,
};
};
5 changes: 5 additions & 0 deletions src/lib/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ const projects: Project[] = [
description: "bad apple in HTML",
slug: "bad-apple",
},
{
title: "video to gif",
description: "a video to gif converter",
slug: "video-to-gif",
},
];

function getProjectBySlug(slug: string) {
Expand Down
12 changes: 12 additions & 0 deletions src/routes/project/video-to-gif.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { clientOnly } from "@solidjs/start";
import { Suspense } from "solid-js";
import VideoGifConverter from "~/components/projects/video-to-gif";

export default function VideoToGif() {
return (
<>
<noscript>this project needs javascript, sorry</noscript>
<VideoGifConverter />
</>
);
}