convert flacs to aac for streaming
This commit is contained in:
8
bun.lock
8
bun.lock
@@ -9,6 +9,8 @@
|
|||||||
"@ai-sdk/react": "^3.0.195",
|
"@ai-sdk/react": "^3.0.195",
|
||||||
"@clerk/nextjs": "^7.4.2",
|
"@clerk/nextjs": "^7.4.2",
|
||||||
"@electric-sql/pglite": "^0.4.6",
|
"@electric-sql/pglite": "^0.4.6",
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
|
"@ffmpeg/util": "^0.12.2",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||||
@@ -354,6 +356,12 @@
|
|||||||
|
|
||||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||||
|
|
||||||
|
"@ffmpeg/ffmpeg": ["@ffmpeg/ffmpeg@0.12.15", "", { "dependencies": { "@ffmpeg/types": "^0.12.4" } }, "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw=="],
|
||||||
|
|
||||||
|
"@ffmpeg/types": ["@ffmpeg/types@0.12.4", "", {}, "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A=="],
|
||||||
|
|
||||||
|
"@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="],
|
||||||
|
|
||||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||||
|
|
||||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"build:ffmpeg-worker": "bun build node_modules/@ffmpeg/ffmpeg/dist/esm/worker.js --target browser --format esm --minify --outfile public/ffmpeg/worker.js",
|
||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
"check:unsafe": "biome check --write --unsafe .",
|
"check:unsafe": "biome check --write --unsafe .",
|
||||||
"check:write": "biome check --write .",
|
"check:write": "biome check --write .",
|
||||||
@@ -23,6 +24,8 @@
|
|||||||
"@ai-sdk/react": "^3.0.195",
|
"@ai-sdk/react": "^3.0.195",
|
||||||
"@clerk/nextjs": "^7.4.2",
|
"@clerk/nextjs": "^7.4.2",
|
||||||
"@electric-sql/pglite": "^0.4.6",
|
"@electric-sql/pglite": "^0.4.6",
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
|
"@ffmpeg/util": "^0.12.2",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||||
|
|||||||
1
public/ffmpeg/worker.js
Normal file
1
public/ffmpeg/worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
var C="https://unpkg.com/@ffmpeg/core@0.12.9/dist/umd/ffmpeg-core.js",N;(function(E){E.LOAD="LOAD",E.EXEC="EXEC",E.FFPROBE="FFPROBE",E.WRITE_FILE="WRITE_FILE",E.READ_FILE="READ_FILE",E.DELETE_FILE="DELETE_FILE",E.RENAME="RENAME",E.CREATE_DIR="CREATE_DIR",E.LIST_DIR="LIST_DIR",E.DELETE_DIR="DELETE_DIR",E.ERROR="ERROR",E.DOWNLOAD="DOWNLOAD",E.PROGRESS="PROGRESS",E.LOG="LOG",E.MOUNT="MOUNT",E.UNMOUNT="UNMOUNT"})(N||(N={}));var G=Error("unknown message type"),V=Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"),b=Error("called FFmpeg.terminate()"),v=Error("failed to import ffmpeg-core.js");var I,X=async({coreURL:E,wasmURL:D,workerURL:O})=>{let S=!I;try{if(!E)E=C;importScripts(E)}catch{if(!E||E===C)E=C.replace("/umd/","/esm/");if(self.createFFmpegCore=(await import(E)).default,!self.createFFmpegCore)throw v}let A=E,x=D?D:E.replace(/.js$/g,".wasm"),B=O?O:E.replace(/.js$/g,".worker.js");return I=await self.createFFmpegCore({mainScriptUrlOrBlob:`${A}#${btoa(JSON.stringify({wasmURL:x,workerURL:B}))}`}),I.setLogger((W)=>self.postMessage({type:N.LOG,data:W})),I.setProgress((W)=>self.postMessage({type:N.PROGRESS,data:W})),S},Y=({args:E,timeout:D=-1})=>{I.setTimeout(D),I.exec(...E);let O=I.ret;return I.reset(),O},j=({args:E,timeout:D=-1})=>{I.setTimeout(D),I.ffprobe(...E);let O=I.ret;return I.reset(),O},J=({path:E,data:D})=>{return I.FS.writeFile(E,D),!0},$=({path:E,encoding:D})=>I.FS.readFile(E,{encoding:D}),q=({path:E})=>{return I.FS.unlink(E),!0},z=({oldPath:E,newPath:D})=>{return I.FS.rename(E,D),!0},H=({path:E})=>{return I.FS.mkdir(E),!0},K=({path:E})=>{let D=I.FS.readdir(E),O=[];for(let S of D){let A=I.FS.stat(`${E}/${S}`),x=I.FS.isDir(A.mode);O.push({name:S,isDir:x})}return O},Q=({path:E})=>{return I.FS.rmdir(E),!0},R=({fsType:E,options:D,mountPoint:O})=>{let S=E,A=I.FS.filesystems[S];if(!A)return!1;return I.FS.mount(A,D,O),!0},Z=({mountPoint:E})=>{return I.FS.unmount(E),!0};self.onmessage=async({data:{id:E,type:D,data:O}})=>{let S=[],A;try{if(D!==N.LOAD&&!I)throw V;switch(D){case N.LOAD:A=await X(O);break;case N.EXEC:A=Y(O);break;case N.FFPROBE:A=j(O);break;case N.WRITE_FILE:A=J(O);break;case N.READ_FILE:A=$(O);break;case N.DELETE_FILE:A=q(O);break;case N.RENAME:A=z(O);break;case N.CREATE_DIR:A=H(O);break;case N.LIST_DIR:A=K(O);break;case N.DELETE_DIR:A=Q(O);break;case N.MOUNT:A=R(O);break;case N.UNMOUNT:A=Z(O);break;default:throw G}}catch(x){self.postMessage({id:E,type:N.ERROR,data:x.toString()});return}if(A instanceof Uint8Array)S.push(A.buffer);self.postMessage({id:E,type:D,data:A},S)};
|
||||||
100
src/app/admin/music/_components/ConvertToStreamButton.tsx
Normal file
100
src/app/admin/music/_components/ConvertToStreamButton.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AudioLines, Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { trpc } from "~/app/_trpc/Client";
|
||||||
|
import { useUploadThing } from "~/lib/uploadthing";
|
||||||
|
import { transcodeToAac } from "~/lib/ffmpeg/transcode";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { RouterOutputs } from "~/server/routers/_app";
|
||||||
|
import type { IterableElement } from "type-fest";
|
||||||
|
|
||||||
|
export default function ConvertToStreamButton(props: {
|
||||||
|
track: IterableElement<RouterOutputs['music']['list']>;
|
||||||
|
}) {
|
||||||
|
const { track } = props;
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [stage, setStage] = useState("");
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
const setStream = trpc.music.setStream.useMutation({
|
||||||
|
onSuccess: () => utils.music.list.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { startUpload } = useUploadThing("musicUploader");
|
||||||
|
|
||||||
|
async function handleConvert() {
|
||||||
|
setBusy(true);
|
||||||
|
setProgress(0);
|
||||||
|
let currentStage = "Loading ffmpeg";
|
||||||
|
const goto = (s: string) => {
|
||||||
|
currentStage = s;
|
||||||
|
setStage(s);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
goto("Transcoding");
|
||||||
|
const file = await transcodeToAac({
|
||||||
|
sourceUrl: track.fileUrl,
|
||||||
|
outputName: `${track.title || "track"}.m4a`,
|
||||||
|
onProgress: setProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
goto("Uploading");
|
||||||
|
const uploaded = await startUpload([file]);
|
||||||
|
const res = uploaded?.[0];
|
||||||
|
if (!res) throw new Error("Upload returned no file");
|
||||||
|
|
||||||
|
goto("Saving");
|
||||||
|
await setStream.mutateAsync({
|
||||||
|
id: track.id,
|
||||||
|
streamUrl: res.serverData.fileUrl,
|
||||||
|
streamKey: res.serverData.fileKey,
|
||||||
|
streamName: res.serverData.fileName,
|
||||||
|
});
|
||||||
|
toast("Streaming version saved");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[ConvertToStream] failed during", currentStage, e);
|
||||||
|
const detail =
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: typeof e === "string"
|
||||||
|
? e
|
||||||
|
: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(e);
|
||||||
|
} catch {
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
toast(`Conversion failed (${currentStage}): ${detail || "see console for details"}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setStage("");
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={handleConvert}>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : track.streamUrl ? (
|
||||||
|
<RefreshCw />
|
||||||
|
) : (
|
||||||
|
<AudioLines />
|
||||||
|
)}
|
||||||
|
{busy
|
||||||
|
? `${stage}${stage === "Transcoding" && progress ? ` ${Math.round(progress * 100)}%` : "…"}`
|
||||||
|
: track.streamUrl
|
||||||
|
? "Re-generate stream"
|
||||||
|
: "Generate stream (AAC)"}
|
||||||
|
</Button>
|
||||||
|
{track.streamUrl && !busy && (
|
||||||
|
<span className="text-xs text-muted-foreground">Streaming version ready</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { trpc } from "~/app/_trpc/Client";
|
import { trpc } from "~/app/_trpc/Client";
|
||||||
import * as Card from "~/components/ui/card";
|
import * as Card from "~/components/ui/card";
|
||||||
import UploadMusicForm from "./_components/UploadMusicForm";
|
import UploadMusicForm from "./_components/UploadMusicForm";
|
||||||
|
import ConvertToStreamButton from "./_components/ConvertToStreamButton";
|
||||||
import { CollapsibleForm } from "~/app/_components/Form/Components";
|
import { CollapsibleForm } from "~/app/_components/Form/Components";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
@@ -14,10 +15,11 @@ export default function AdminMusicPage() {
|
|||||||
{tracks && <>
|
{tracks && <>
|
||||||
{tracks.map((t) => (
|
{tracks.map((t) => (
|
||||||
<Card.Card key={t.id}>
|
<Card.Card key={t.id}>
|
||||||
<Card.CardContent>
|
<Card.CardContent className="flex flex-col gap-4">
|
||||||
<UploadMusicForm entity={t} className="w-full"/>
|
<UploadMusicForm entity={t} className="w-full"/>
|
||||||
|
<ConvertToStreamButton track={t} />
|
||||||
</Card.CardContent>
|
</Card.CardContent>
|
||||||
</Card.Card>
|
</Card.Card>
|
||||||
))}
|
))}
|
||||||
</>}
|
</>}
|
||||||
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
|
<CollapsibleForm entityName="Track" form={UploadMusicForm}/>
|
||||||
|
|||||||
125
src/app/music/_components/AudioPlayer.tsx
Normal file
125
src/app/music/_components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Download, Loader2, Pause, Play } from "lucide-react";
|
||||||
|
import { Slider } from "~/components/ui/slider";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
if (!Number.isFinite(seconds)) return "0:00";
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer(props: {
|
||||||
|
/** Streaming-friendly source the player actually plays. */
|
||||||
|
src: string;
|
||||||
|
/** Original high-quality file offered via the download button. */
|
||||||
|
downloadUrl: string;
|
||||||
|
downloadName: string;
|
||||||
|
}) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [seeking, setSeeking] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
// The download file is cross-origin, so the <a download> attribute is
|
||||||
|
// ignored — fetch it as a blob to force a real download.
|
||||||
|
const res = await fetch(props.downloadUrl);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = props.downloadName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
toast(`Download failed: ${e instanceof Error ? e.message : "unknown error"}`);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={props.src}
|
||||||
|
preload="metadata"
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||||
|
onTimeUpdate={(e) => {
|
||||||
|
if (!seeking) setCurrent(e.currentTarget.currentTime);
|
||||||
|
}}
|
||||||
|
onEnded={() => setPlaying(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={playing ? "Pause" : "Play"}
|
||||||
|
onClick={togglePlay}
|
||||||
|
>
|
||||||
|
{playing ? <Pause /> : <Play />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="w-10 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
{formatTime(current)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
className="flex-1"
|
||||||
|
min={0}
|
||||||
|
max={duration || 1}
|
||||||
|
step={0.1}
|
||||||
|
value={[current]}
|
||||||
|
onValueChange={([v]) => {
|
||||||
|
setSeeking(true);
|
||||||
|
setCurrent(v ?? 0);
|
||||||
|
}}
|
||||||
|
onValueCommit={([v]) => {
|
||||||
|
if (audioRef.current) audioRef.current.currentTime = v ?? 0;
|
||||||
|
setSeeking(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
{formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Download lossless file"
|
||||||
|
title="Download lossless file"
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
{downloading ? <Loader2 className="animate-spin" /> : <Download />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Spinner } from "~/components/ui/spinner";
|
|||||||
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||||
|
import AudioPlayer from "./_components/AudioPlayer";
|
||||||
export default function MusicPage() {
|
export default function MusicPage() {
|
||||||
const { data: tracks, isLoading } = trpc.music.list.useQuery();
|
const { data: tracks, isLoading } = trpc.music.list.useQuery();
|
||||||
useTimeLine(tracks)
|
useTimeLine(tracks)
|
||||||
@@ -41,9 +42,11 @@ export default function MusicPage() {
|
|||||||
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
|
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
|
||||||
)}
|
)}
|
||||||
<AnimatePopUp position={i + 1.3}>
|
<AnimatePopUp position={i + 1.3}>
|
||||||
<audio controls className="w-full player" src={track.fileUrl}>
|
<AudioPlayer
|
||||||
Your browser does not support the audio element.
|
src={track.streamUrl ?? track.fileUrl}
|
||||||
</audio>
|
downloadUrl={track.fileUrl}
|
||||||
|
downloadName={track.fileName}
|
||||||
|
/>
|
||||||
</AnimatePopUp>
|
</AnimatePopUp>
|
||||||
</Card.CardContent>
|
</Card.CardContent>
|
||||||
</Card.AnimatedCard>
|
</Card.AnimatedCard>
|
||||||
|
|||||||
114
src/lib/ffmpeg/transcode.ts
Normal file
114
src/lib/ffmpeg/transcode.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||||
|
import { fetchFile, toBlobURL } from "@ffmpeg/util";
|
||||||
|
|
||||||
|
// Single-threaded core: works without SharedArrayBuffer, so no COOP/COEP
|
||||||
|
// cross-origin-isolation headers are required. Slower than the MT core, which
|
||||||
|
// is fine for occasional admin-side transcodes.
|
||||||
|
const CORE_VERSION = "0.12.10";
|
||||||
|
// @ffmpeg/ffmpeg 0.12.x always spawns a `type: "module"` worker. A module
|
||||||
|
// worker can't `importScripts`, so the worker dynamically `import()`s the core —
|
||||||
|
// which means the core must be the ESM build, not UMD.
|
||||||
|
const CORE_BASE = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/esm`;
|
||||||
|
// Self-hosted worker, bundled from @ffmpeg/ffmpeg's ESM `worker.js` via the
|
||||||
|
// `build:ffmpeg-worker` package script (rerun it when bumping @ffmpeg/ffmpeg).
|
||||||
|
// The shipped UMD worker is unusable here: its
|
||||||
|
// dynamic import is webpack-compiled into a stub that throws MODULE_NOT_FOUND,
|
||||||
|
// and letting Next bundle the worker hits the same problem. Serving our own
|
||||||
|
// same-origin module worker keeps the native `import()` intact.
|
||||||
|
// Must be an absolute URL: the library resolves classWorkerURL against
|
||||||
|
// `import.meta.url`, which is a file:// base under Next, so a root-relative path
|
||||||
|
// would wrongly become `file:///ffmpeg/worker.js`.
|
||||||
|
const WORKER_PATH = "/ffmpeg/worker.js";
|
||||||
|
|
||||||
|
let instance: FFmpeg | null = null;
|
||||||
|
let loadPromise: Promise<FFmpeg> | null = null;
|
||||||
|
|
||||||
|
function load(onLog?: (msg: string) => void): Promise<FFmpeg> {
|
||||||
|
if (instance) return Promise.resolve(instance);
|
||||||
|
if (!loadPromise) {
|
||||||
|
loadPromise = (async () => {
|
||||||
|
const ffmpeg = new FFmpeg();
|
||||||
|
ffmpeg.on("log", ({ message }) => {
|
||||||
|
console.debug("[ffmpeg]", message);
|
||||||
|
onLog?.(message);
|
||||||
|
});
|
||||||
|
const ok = await ffmpeg.load({
|
||||||
|
coreURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.js`, "text/javascript"),
|
||||||
|
wasmURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"),
|
||||||
|
classWorkerURL: new URL(WORKER_PATH, window.location.origin).href,
|
||||||
|
});
|
||||||
|
if (!ok) throw new Error("ffmpeg.load() returned false — core failed to initialise");
|
||||||
|
instance = ffmpeg;
|
||||||
|
return ffmpeg;
|
||||||
|
})();
|
||||||
|
// If loading fails, clear the cached promise so the next click retries.
|
||||||
|
loadPromise.catch(() => {
|
||||||
|
loadPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcodes a (FLAC) source URL to a streaming-friendly AAC/m4a file in the
|
||||||
|
* browser. Returns a File ready to hand to UploadThing's startUpload.
|
||||||
|
*/
|
||||||
|
export async function transcodeToAac(opts: {
|
||||||
|
sourceUrl: string;
|
||||||
|
outputName?: string;
|
||||||
|
bitrate?: string;
|
||||||
|
onProgress?: (ratio: number) => void;
|
||||||
|
onLog?: (msg: string) => void;
|
||||||
|
}): Promise<File> {
|
||||||
|
let lastLog = "";
|
||||||
|
const onLog = (msg: string) => {
|
||||||
|
if (msg.trim()) lastLog = msg;
|
||||||
|
opts.onLog?.(msg);
|
||||||
|
};
|
||||||
|
const ffmpeg = await load(onLog);
|
||||||
|
// `load` only wires the persisted instance's log handler on first init; make
|
||||||
|
// sure this call's logs are captured even when the instance was cached.
|
||||||
|
const logHandler = ({ message }: { message: string }) => onLog(message);
|
||||||
|
ffmpeg.on("log", logHandler);
|
||||||
|
const progressHandler = opts.onProgress
|
||||||
|
? ({ progress }: { progress: number }) =>
|
||||||
|
opts.onProgress?.(Math.min(1, Math.max(0, progress)))
|
||||||
|
: null;
|
||||||
|
if (progressHandler) ffmpeg.on("progress", progressHandler);
|
||||||
|
try {
|
||||||
|
// fetchFile pulls the source cross-origin; surface a clear message if the
|
||||||
|
// host (UploadThing) blocks it.
|
||||||
|
let sourceData: Uint8Array;
|
||||||
|
try {
|
||||||
|
sourceData = await fetchFile(opts.sourceUrl);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not fetch source file (CORS or network): ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await ffmpeg.writeFile("input.flac", sourceData);
|
||||||
|
const code = await ffmpeg.exec([
|
||||||
|
"-i", "input.flac",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", opts.bitrate ?? "192k",
|
||||||
|
// Move the moov atom to the front so the player can start before the
|
||||||
|
// whole file has downloaded — the whole point of the stream version.
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"output.m4a",
|
||||||
|
]);
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(`ffmpeg exited with code ${code}: ${lastLog || "no log output"}`);
|
||||||
|
}
|
||||||
|
const data = await ffmpeg.readFile("output.m4a");
|
||||||
|
await ffmpeg.deleteFile("input.flac");
|
||||||
|
await ffmpeg.deleteFile("output.m4a");
|
||||||
|
// Copy into a fresh ArrayBuffer-backed view so the Blob typing is happy
|
||||||
|
// (readFile may be typed against a SharedArrayBuffer-backed Uint8Array).
|
||||||
|
const bytes = new Uint8Array(data as Uint8Array);
|
||||||
|
const blob = new Blob([bytes], { type: "audio/mp4" });
|
||||||
|
return new File([blob], opts.outputName ?? "stream.m4a", { type: "audio/mp4" });
|
||||||
|
} finally {
|
||||||
|
ffmpeg.off("log", logHandler);
|
||||||
|
if (progressHandler) ffmpeg.off("progress", progressHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ export const createMusicInputSchema = z.object({
|
|||||||
fileUrl: z.string(),
|
fileUrl: z.string(),
|
||||||
fileKey: z.string(),
|
fileKey: z.string(),
|
||||||
fileName: z.string(),
|
fileName: z.string(),
|
||||||
|
streamUrl: z.string().optional().nullable(),
|
||||||
|
streamKey: z.string().optional().nullable(),
|
||||||
|
streamName: z.string().optional().nullable(),
|
||||||
})
|
})
|
||||||
export const updateMusicInputSchema = z.object({
|
export const updateMusicInputSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
@@ -15,4 +18,14 @@ export const updateMusicInputSchema = z.object({
|
|||||||
fileUrl: z.string().optional(),
|
fileUrl: z.string().optional(),
|
||||||
fileKey: z.string().optional(),
|
fileKey: z.string().optional(),
|
||||||
fileName: z.string().optional(),
|
fileName: z.string().optional(),
|
||||||
|
streamUrl: z.string().optional().nullable(),
|
||||||
|
streamKey: z.string().optional().nullable(),
|
||||||
|
streamName: z.string().optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setStreamInputSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
streamUrl: z.string(),
|
||||||
|
streamKey: z.string(),
|
||||||
|
streamName: z.string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
|
import { generateReactHelpers, generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
|
||||||
import type { FileRouter } from "~/server/uploadthing";
|
import type { FileRouter } from "~/server/uploadthing";
|
||||||
|
|
||||||
export const UploadButton = generateUploadButton<FileRouter>();
|
export const UploadButton = generateUploadButton<FileRouter>();
|
||||||
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
||||||
|
|
||||||
|
export const { useUploadThing } = generateReactHelpers<FileRouter>();
|
||||||
|
|||||||
@@ -94,9 +94,14 @@ export const music = createTable(
|
|||||||
id: d.uuid().primaryKey().notNull(),
|
id: d.uuid().primaryKey().notNull(),
|
||||||
title: d.varchar({ length: 100 }).notNull(),
|
title: d.varchar({ length: 100 }).notNull(),
|
||||||
description: d.text(),
|
description: d.text(),
|
||||||
|
// Original high-quality upload (e.g. FLAC), offered as a download.
|
||||||
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||||
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||||
fileName: d.varchar("file_name", { length: 200 }).notNull(),
|
fileName: d.varchar("file_name", { length: 200 }).notNull(),
|
||||||
|
// Lighter, streaming-friendly transcode (e.g. AAC) used by the player.
|
||||||
|
streamUrl: d.varchar("stream_url", { length: 500 }),
|
||||||
|
streamKey: d.varchar("stream_key", { length: 200 }),
|
||||||
|
streamName: d.varchar("stream_name", { length: 200 }),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp({ withTimezone: true })
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { isAdmin } from "~/app/actions";
|
import { isAdmin } from "~/app/actions";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas";
|
import { createMusicInputSchema, updateMusicInputSchema, setStreamInputSchema } from "~/lib/trpc/music/schemas";
|
||||||
import { utapi } from "../uploadthing";
|
import { utapi } from "../uploadthing";
|
||||||
export const musicRouter = router({
|
export const musicRouter = router({
|
||||||
list: publicProcedure.query(async () => {
|
list: publicProcedure.query(async () => {
|
||||||
@@ -33,6 +33,22 @@ export const musicRouter = router({
|
|||||||
const { id, ...data } = input;
|
const { id, ...data } = input;
|
||||||
return db.update(music).set(data).where(eq(music.id, id)).returning();
|
return db.update(music).set(data).where(eq(music.id, id)).returning();
|
||||||
}),
|
}),
|
||||||
|
setStream: publicProcedure
|
||||||
|
.input(setStreamInputSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||||
|
const { id, ...data } = input;
|
||||||
|
const existing = await db.select().from(music).where(eq(music.id, id)).limit(1);
|
||||||
|
const prev = existing.at(0);
|
||||||
|
if (!prev) throw new TRPCError({ code: "NOT_FOUND", message: "Track not found" });
|
||||||
|
const res = await db.update(music).set(data).where(eq(music.id, id)).returning();
|
||||||
|
// Drop the previous stream transcode so we don't orphan it on UploadThing.
|
||||||
|
if (prev.streamKey && prev.streamKey !== data.streamKey) {
|
||||||
|
utapi.deleteFiles(prev.streamKey);
|
||||||
|
}
|
||||||
|
return res.at(0);
|
||||||
|
}),
|
||||||
delete: publicProcedure
|
delete: publicProcedure
|
||||||
.input(z.object({id:z.string().uuid()}))
|
.input(z.object({id:z.string().uuid()}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -41,7 +57,8 @@ export const musicRouter = router({
|
|||||||
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
|
let res = await db.delete(music).where(eq(music.id, input.id)).returning();
|
||||||
let ret = res.at(0)
|
let ret = res.at(0)
|
||||||
if (ret) {
|
if (ret) {
|
||||||
utapi.deleteFiles(ret.fileKey)
|
const keys = [ret.fileKey, ret.streamKey].filter((k): k is string => !!k);
|
||||||
|
utapi.deleteFiles(keys)
|
||||||
}
|
}
|
||||||
return ret;
|
return ret;
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user