Complete Playground v1

This commit is contained in:
Jerome Wu
2022-10-07 17:54:57 +08:00
parent e02437421b
commit f4a27e3491
12 changed files with 500 additions and 270 deletions

View File

@@ -1,53 +0,0 @@
import * as React from "react";
import Button from "@mui/material/Button";
import Container from "@mui/material/Container";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormControl from "@mui/material/FormControl";
import FormLabel from "@mui/material/FormLabel";
import styles from "./styles.module.css";
interface CoreSelectorProps {
option: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => any;
onSubmit: () => any;
}
export default function CoreSelector({
option,
onChange,
onSubmit,
}: CoreSelectorProps) {
return (
<Container className={styles.margin}>
<Container className={styles.margin}>
<FormControl>
<FormLabel id="core-selector">Select a Core Option</FormLabel>
<RadioGroup
aria-labelledby="core-selector"
name="core-selector"
value={option}
onChange={onChange}
>
<FormControlLabel
value="core"
control={<Radio />}
label="Core (Slower, but stable)"
/>
<FormControlLabel
value="core-mt"
disabled={typeof SharedArrayBuffer !== "function"}
control={<Radio />}
label="Core Multi-threaded (Faster, but lower compatibility and unstable)"
/>
</RadioGroup>
</FormControl>
</Container>
<Container>
<Button variant="contained" onClick={onSubmit}>
Load
</Button>
</Container>
</Container>
);
}

View File

@@ -0,0 +1,34 @@
import React from "react";
import Stack from "@mui/material/Stack";
import FormGroup from "@mui/material/FormGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import IconButton from "@mui/material/IconButton";
import HelpIcon from "@mui/icons-material/HelpOutline";
import Tooltip from "@mui/material/Tooltip";
interface CoreSwitcherProps {
checked: boolean;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function CoreSwitcher({ checked, onChange }: CoreSwitcherProps) {
return (
<>
<Stack direction="row" justifyContent="flex-end">
<FormGroup>
<FormControlLabel
control={<Switch checked={checked} onChange={onChange} />}
label="Use Multi-thread"
disabled={typeof SharedArrayBuffer !== "function"}
/>
</FormGroup>
<Tooltip title="Multi-threaded core is faster, but unstable and not supported by all browsers. Click here for more details.">
<IconButton aria-label="help" size="small">
<HelpIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</>
);
}

View File

@@ -12,29 +12,47 @@ import ListItem from "@mui/material/ListItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import IconButton from "@mui/material/IconButton";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import RefreshIcon from "@mui/icons-material/Refresh";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import UploadIcon from "@mui/icons-material/Upload";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import Tooltip from "@mui/material/Tooltip";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Paper from "@mui/material/Paper";
import { useColorMode } from "@docusaurus/theme-common";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
import { downloadFile } from "@site/src/util";
import AceEditor from "react-ace";
import styles from "./styles.module.css";
import { getFFmpeg } from "./ffmpeg";
import { SAMPLE_FILES } from "./const";
import LinearProgressWithLabel from "./LinearProgressWithLabel";
import MoreButton from "./MoreButton";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-text";
import "ace-builds/src-noconflict/theme-dracula";
import "ace-builds/src-noconflict/theme-github";
import ListItemButton from "@mui/material/ListItemButton";
import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
const defaultArgs = JSON.stringify(["-i", "video.avi", "video.mp4"], null, 2);
const options = ["Download", "Download as Text File", "Delete"];
const options = [
{ text: "Download", key: "download" },
{ text: "Download as Text File", key: "download-text" },
{ text: "Delete", key: "delete" },
];
const modalStyle = {
position: "absolute" as "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "background.paper",
p: 4,
};
const genFFmpegText = (args: string) => {
let data: any = [];
@@ -54,33 +72,42 @@ export default function Editor() {
const [logs, setLogs] = useState<string[]>([]);
const [output, setOutput] = useState<Ace.Editor>();
const [path, setPath] = useState<string>("/");
const [nodes, setNodes] = useState<string[]>([]);
const [progress, setProgress] = useState<number>(-1);
const [nodes, setNodes] = useState([]);
const [progress, setProgress] = useState<number>(0);
const [open, setOpen] = useState(false);
const [time, setTime] = useState(0);
const [folderName, setFolderName] = useState("");
const handleModalOpen = () => setOpen(true);
const handleModalClose = () => setOpen(false);
const { colorMode } = useColorMode();
const theme = colorMode === "dark" ? "github" : "dracula";
const scrollToEnd = () => {
output && output.renderer.scrollToLine(Number.POSITIVE_INFINITY);
};
const loadSamples = async () => {
const refreshDir = async (curPath: string) => {
const ffmpeg = getFFmpeg();
Object.keys(SAMPLE_FILES).forEach(async (name) => {
await ffmpeg.writeFile(name, await fetchFile(SAMPLE_FILES[name]));
});
// Somehow we need to wait a little bit before reading the new nodes.
setTimeout(async () => {
setNodes(await ffmpeg.listDir(path));
}, 500);
if (ffmpeg.loaded) {
setNodes(
(await ffmpeg.listDir(curPath)).filter(({ name }) => name !== ".")
);
}
};
const refreshDir = async () => {
setNodes(await getFFmpeg().listDir(path));
const loadSamples = async () => {
const ffmpeg = getFFmpeg();
for (const name of Object.keys(SAMPLE_FILES)) {
await ffmpeg.writeFile(name, await fetchFile(SAMPLE_FILES[name]));
}
refreshDir(path);
};
const exec = async () => {
const ffmpeg = getFFmpeg();
setProgress(-1);
setProgress(0);
setTime(0);
const logListener = ({ message }) => {
setLogs((_logs) => [..._logs, message]);
scrollToEnd();
@@ -90,157 +117,271 @@ export default function Editor() {
};
ffmpeg.on(FFmpeg.LOG, logListener);
ffmpeg.on(FFmpeg.PROGRESS, progListener);
const start = performance.now();
await ffmpeg.exec(JSON.parse(args));
setTime(performance.now() - start);
ffmpeg.removeListener(FFmpeg.LOG, logListener);
ffmpeg.removeListener(FFmpeg.PROGRESS, progListener);
setNodes(await ffmpeg.listDir(path));
refreshDir(path);
};
const cd = (name: string) => async () => {
let nextPath = path;
if (path === "/") {
if (name !== "..") nextPath = `/${name}`;
} else if (name === "..") {
const cols = path.split("/");
cols.pop();
nextPath = cols.length === 1 ? "/" : cols.join("/");
} else {
nextPath = `${path}/${name}`;
}
setPath(nextPath);
refreshDir(nextPath);
};
const handleFileUpload =
(isText: boolean = false) =>
async ({ target: { files } }: React.ChangeEvent<HTMLInputElement>) => {
const ffmpeg = getFFmpeg();
for (let i = 0; i < files.length; i++) {
const file = files[i];
let data: Uint8Array | string = await fetchFile(file);
if (isText) data = new TextDecoder().decode(data);
await ffmpeg.writeFile(`${path}/${file.name}`, data);
}
refreshDir(path);
};
const handleItemClick = (name: string) => async (option: string) => {
const ffmpeg = getFFmpeg();
const fullPath = `${path}/${name}`;
switch (option) {
case "download":
downloadFile(
name,
((await ffmpeg.readFile(fullPath, "binary")) as Uint8Array).buffer
);
break;
case "download-text":
downloadFile(name, await ffmpeg.readFile(fullPath, "utf8"));
break;
case "delete":
await ffmpeg.deleteFile(fullPath);
refreshDir(path);
break;
default:
break;
}
};
const handleFolderCreate = async () => {
if (folderName !== "") {
await getFFmpeg().createDir(`${path}/${folderName}`);
}
refreshDir(path);
handleModalClose();
};
useEffect(() => {
const ffmpeg = getFFmpeg();
ffmpeg.listDir(path).then((nodes) => {
setNodes(nodes);
});
refreshDir(path);
}, []);
return (
<Box sx={{ flexGrow: 1 }}>
<Grid
container
spacing={{ xs: 2, md: 3 }}
columns={{ xs: 4, sm: 8, md: 12 }}
>
<Grid container spacing={{ xs: 1 }} columns={{ xs: 4, md: 12 }}>
<Grid item xs={4}>
<Stack direction="row" className={styles.fsTitle}>
<Typography>File System:</Typography>
<Box>
<Tooltip title="Upload a media file">
<IconButton onClick={() => {}} aria-label="upload-media-file">
<UploadFileIcon />
</IconButton>
</Tooltip>
<Tooltip title="Upload a text file">
<IconButton onClick={() => {}} aria-label="upload-text">
<UploadIcon />
</IconButton>
</Tooltip>
<Tooltip title="Refresh directory">
<IconButton onClick={refreshDir} aria-label="fresh">
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
</Stack>
<Typography>{`${path}`}</Typography>
<List dense={true}>
{nodes.map((node, index) => (
<ListItem
key={index}
secondaryAction={
<>
<IconButton
aria-label="more"
id="long-button"
aria-haspopup="true"
edge="end"
>
<MoreVertIcon />
</IconButton>
<Menu
id="long-menu"
MenuListProps={{
"aria-labelledby": "long-button",
}}
PaperProps={{
style: {
width: "20ch",
},
}}
>
{options.map((option) => (
<MenuItem key={option} selected={option === "Download"}>
{option}
</MenuItem>
))}
</Menu>
</>
}
>
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
<ListItemText primary={node} />
</ListItem>
))}
</List>
<Paper variant="outlined" style={{ padding: 8, height: "100%" }}>
<Stack justifyContent="space-between" style={{ height: "100%" }}>
<>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography>File System:</Typography>
<Box>
<Tooltip title="Upload a media file">
<IconButton
aria-label="upload-media-file"
component="label"
size="small"
>
<input
hidden
multiple
type="file"
onChange={handleFileUpload(false)}
/>
<UploadFileIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Upload a text file">
<IconButton
onClick={() => {}}
aria-label="upload-text"
component="label"
size="small"
>
<input
hidden
multiple
type="file"
onChange={handleFileUpload(true)}
/>
<UploadIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Create a new folder">
<IconButton
onClick={() => {
setFolderName("");
handleModalOpen();
}}
aria-label="create-a-new-folder"
size="small"
>
<CreateNewFolderIcon />
</IconButton>
</Tooltip>
<Tooltip title="Refresh directory">
<IconButton
onClick={() => refreshDir(path)}
aria-label="fresh"
size="small"
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Stack>
<Typography>{`Path: ${path}`}</Typography>
<List style={{ height: 480, overflowX: "auto" }}>
{nodes.map(({ name, isDir }, index) =>
isDir ? (
<ListItemButton key={index} onClick={cd(name)}>
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
<ListItemText primary={name} />
</ListItemButton>
) : (
<ListItem
key={index}
secondaryAction={
<MoreButton
options={options}
onItemClick={handleItemClick(name)}
/>
}
>
<ListItemIcon>
<InsertDriveFileIcon />
</ListItemIcon>
<ListItemText primary={name} />
</ListItem>
)
)}
</List>
</>
<Button onClick={loadSamples}>Load Sample Files</Button>
</Stack>
</Paper>
</Grid>
<Grid item xs={8}>
<Stack spacing={1}>
<Box>
<Typography>Edit JSON below to update command:</Typography>
<Paper variant="outlined" style={{ padding: 8, height: "100%" }}>
<Stack spacing={1}>
<Stack>
<Typography>Edit JSON below to update command:</Typography>
<AceEditor
mode="json"
theme={theme}
name="input-args"
fontSize={16}
showPrintMargin={true}
showGutter={true}
width="100%"
minLines={8}
maxLines={8}
highlightActiveLine={true}
value={args}
onChange={(value) => setArgs(value)}
setOptions={{ tabSize: 2 }}
/>
</Stack>
<AceEditor
mode="json"
mode="javascript"
theme={theme}
name="input-args"
name="ffmpeg.wasm"
fontSize={16}
showGutter={false}
width="100%"
minLines={6}
maxLines={6}
readOnly
highlightActiveLine={false}
value={genFFmpegText(args)}
setOptions={{ tabSize: 2 }}
/>
<Typography>Console Output:</Typography>
<AceEditor
mode="text"
theme={theme}
name="console"
fontSize={16}
showPrintMargin={true}
showGutter={true}
width="100%"
minLines={8}
maxLines={8}
highlightActiveLine={true}
value={args}
onChange={(value) => setArgs(value)}
readOnly
showPrintMargin={true}
highlightActiveLine={false}
value={logs.join("\n")}
setOptions={{ tabSize: 2 }}
onLoad={(editor) => setOutput(editor)}
/>
</Box>
<AceEditor
mode="javascript"
theme={theme}
name="ffmpeg.wasm"
fontSize={16}
showGutter={false}
width="100%"
minLines={6}
maxLines={6}
readOnly
highlightActiveLine={false}
value={genFFmpegText(args)}
setOptions={{ tabSize: 2 }}
/>
<Stack direction="row" spacing={2} className={styles.alignRight}>
<Button onClick={loadSamples}>Load Sample Files</Button>
<Button variant="contained" onClick={exec}>
Run
</Button>
<Typography>Transcoding Progress:</Typography>
<LinearProgressWithLabel value={progress} />
<Stack direction="row" spacing={2} justifyContent="space-between">
<Typography>
{time === 0
? ""
: `Time Elapsed: ${(time / 1000).toFixed(2)} s`}
</Typography>
<Button variant="contained" onClick={exec}>
Run
</Button>
</Stack>
</Stack>
{progress === -1 ? (
<></>
) : (
<>
<Typography>Transcoding Progress:</Typography>
<LinearProgressWithLabel value={progress} />
</>
)}
<AceEditor
placeholder="ffmpeg console output"
mode="text"
theme={theme}
name="console"
fontSize={16}
width="100%"
minLines={8}
maxLines={8}
readOnly
showPrintMargin={true}
highlightActiveLine={false}
value={logs.join("\n")}
setOptions={{ tabSize: 2 }}
onLoad={(editor) => setOutput(editor)}
/>
</Stack>
</Paper>
</Grid>
</Grid>
<Modal
open={open}
onClose={handleModalClose}
aria-labelledby="new-folder-name"
aria-describedby="new-folder-name-description"
>
<Box sx={modalStyle}>
<Stack spacing={4}>
<Typography id="modal-modal-title" variant="h6" component="h2">
Folder Name:
</Typography>
<TextField
id="outlined-basic"
label="my-folder"
variant="outlined"
value={folderName}
onChange={(event) => setFolderName(event.target.value)}
/>
<Stack direction="row" justifyContent="flex-end">
<Button onClick={handleModalClose}>Cancel</Button>
<Button variant="contained" onClick={handleFolderCreate}>
Create
</Button>
</Stack>
</Stack>
</Box>
</Modal>
</Box>
);
}

View File

@@ -0,0 +1,69 @@
import * as React from "react";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import MoreVertIcon from "@mui/icons-material/MoreVert";
const ITEM_HEIGHT = 48;
interface Option {
key: string;
text: string;
}
interface MoreButtonProps {
options: Option[];
onItemClick: (option: string) => any;
}
export default function MoreButton({ options, onItemClick }: MoreButtonProps) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleItemClick = (option: string) => () => {
onItemClick(option);
setAnchorEl(null);
};
return (
<div>
<IconButton
aria-label="more"
id="more-button"
aria-controls={open ? "menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-haspopup="true"
onClick={handleClick}
>
<MoreVertIcon />
</IconButton>
<Menu
id="menu"
MenuListProps={{
"aria-labelledby": "more-button",
}}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
PaperProps={{
style: {
maxHeight: ITEM_HEIGHT * 4.5,
width: "20ch",
},
}}
>
{options.map(({ text, key }) => (
<MenuItem key={key} onClick={handleItemClick(key)}>
{text}
</MenuItem>
))}
</Menu>
</div>
);
}

View File

@@ -1,9 +1,14 @@
export const CORE_VERSION = "0.12.0-alpha.2";
export const CORE_URL = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.js`;
export const CORE_MT_URL = `https://unpkg.com/@ffmpeg/core-mt@${CORE_VERSION}/dist/umd/ffmpeg-core.js`;
export const CORE_SIZE = {
"https://unpkg.com/@ffmpeg/core@0.12.0-alpha.2/dist/umd/ffmpeg-core.js": 111646,
"https://unpkg.com/@ffmpeg/core@0.12.0-alpha.2/dist/umd/ffmpeg-core.wasm": 31967534,
"https://unpkg.com/@ffmpeg/core-mt@0.12.0-alpha.2/dist/umd/ffmpeg-core.js": 130002,
"https://unpkg.com/@ffmpeg/core-mt@0.12.0-alpha.2/dist/umd/ffmpeg-core.wasm": 32441947,
"https://unpkg.com/@ffmpeg/core-mt@0.12.0-alpha.2/dist/umd/ffmpeg-core.worker.js": 2978,
[`https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.js`]: 111646,
[`https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.wasm`]: 31967534,
[`https://unpkg.com/@ffmpeg/core-mt@${CORE_VERSION}/dist/umd/ffmpeg-core.js`]: 130002,
[`https://unpkg.com/@ffmpeg/core-mt@${CORE_VERSION}/dist/umd/ffmpeg-core.wasm`]: 32441947,
[`https://unpkg.com/@ffmpeg/core-mt@${CORE_VERSION}/dist/umd/ffmpeg-core.worker.js`]: 2978,
};
export const SAMPLE_FILES = {

View File

@@ -1,10 +1,12 @@
import * as React from "react";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import Stack from "@mui/material/Stack";
import MuiThemeProvider from "@site/src/components/MuiThemeProvider";
import CoreSelector from "./CoreSelector";
import CoreDownloader from "./CoreDownloader";
import Editor from "./Editor";
import { getFFmpeg } from "./ffmpeg";
import { CORE_URL, CORE_MT_URL } from "./const";
import CoreSwitcher from "./CoreSwitcher";
enum State {
NOT_LOADED,
@@ -13,12 +15,12 @@ enum State {
}
export default function Playground() {
const { useState } = React;
const { useState, useEffect } = React;
const [state, setState] = useState(State.LOADED);
const [option, setOption] = useState("core");
const [isCoreMT, setIsCoreMT] = useState(false);
const [url, setURL] = useState("");
const [received, setReceived] = useState(0);
const load = async () => {
const load = async (mt: boolean = false) => {
setState(State.LOADING);
const ffmpeg = getFFmpeg();
ffmpeg.terminate();
@@ -26,29 +28,38 @@ export default function Playground() {
setURL(_url as string);
setReceived(_received);
});
await ffmpeg.load();
await ffmpeg.load({
coreURL: mt ? CORE_MT_URL : CORE_URL,
thread: mt,
});
setState(State.LOADED);
};
useEffect(() => {
load(isCoreMT);
}, []);
return (
<MuiThemeProvider>
{(() => {
switch (state) {
case State.LOADING:
return <CoreDownloader url={url} received={received} />;
case State.LOADED:
return <Editor />;
default:
return <></>;
}
})()}
<CoreSelector
option={option}
onChange={(event) => {
setOption((event.target as HTMLInputElement).value);
}}
onSubmit={load}
/>
<Stack spacing={4}>
<CoreSwitcher
checked={isCoreMT}
onChange={(evt) => {
setIsCoreMT(evt.target.checked);
load(evt.target.checked);
}}
/>
{(() => {
switch (state) {
case State.LOADING:
return <CoreDownloader url={url} received={received} />;
case State.LOADED:
return <Editor />;
default:
return <></>;
}
})()}
</Stack>
</MuiThemeProvider>
);
}

View File

@@ -1,13 +0,0 @@
.margin {
margin-bottom: 32px;
}
.alignRight {
display: flex;
justify-content: flex-end;
}
.fsTitle {
align-items: center;
justify-content: space-between;
}

9
apps/website/src/util.ts Normal file
View File

@@ -0,0 +1,9 @@
export const downloadFile = (name: string, data: ArrayBuffer | string) => {
const a = document.createElement("a");
const blob = new Blob([data]);
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
};