@liqvid/studio 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/dist/esm/LiqvidDevToolsProvider.js +49 -0
- package/dist/esm/api/contract.mjs +48 -0
- package/dist/esm/api/project-meta.mjs +33 -0
- package/dist/esm/api/recording.mjs +156 -0
- package/dist/esm/api/root.mjs +4 -0
- package/dist/esm/api/static-file.mjs +82 -0
- package/dist/esm/api/types.mjs +1 -0
- package/dist/esm/client.mjs +98 -0
- package/dist/esm/conventions.mjs +3 -0
- package/dist/esm/index.mjs +20 -0
- package/dist/esm/initialize.mjs +50 -0
- package/dist/esm/jobs/watch-assets.mjs +99 -0
- package/dist/esm/jobs/watch-project-files.mjs +216 -0
- package/dist/esm/next/api.mjs +48 -0
- package/dist/esm/next/page.js +15 -0
- package/dist/esm/pages/NewProjectButton.js +62 -0
- package/dist/esm/pages/RebuildButton.js +24 -0
- package/dist/esm/pages/root-actions.js +151 -0
- package/dist/esm/pages/root.js +25 -0
- package/dist/esm/pages/root.module.css +326 -0
- package/dist/esm/palette.css +279 -0
- package/dist/esm/providers/hosting/github-pages.mjs +10 -0
- package/dist/esm/providers/hosting/liqvid-studio.mjs +8 -0
- package/dist/esm/providers/hosting/s3.mjs +9 -0
- package/dist/esm/providers/hosting/sftp.mjs +22 -0
- package/dist/esm/providers/index.mjs +10 -0
- package/dist/esm/providers/social/bluesky.mjs +8 -0
- package/dist/esm/providers/social/facebook.mjs +8 -0
- package/dist/esm/providers/social/instagram.mjs +8 -0
- package/dist/esm/providers/social/twitter.mjs +7 -0
- package/dist/esm/providers/social/youtube.mjs +7 -0
- package/dist/esm/providers/types.mjs +1 -0
- package/dist/esm/publish.mjs +37 -0
- package/dist/esm/recording/RecordingControl.js +110 -0
- package/dist/esm/recording/RecordingControl.module.css +0 -0
- package/dist/esm/recording/RecordingDialog.js +114 -0
- package/dist/esm/recording/RecordingDialog.module.css +194 -0
- package/dist/esm/schemas/liqvid-config.mjs +32 -0
- package/dist/esm/schemas/project.mjs +27 -0
- package/dist/esm/schemas/recording-meta.mjs +11 -0
- package/dist/esm/types/assets.mjs +1 -0
- package/dist/esm/types.mjs +12 -0
- package/dist/esm/ui/Dialog.js +71 -0
- package/dist/esm/ui/DockableDialog.js +131 -0
- package/dist/esm/ui/DockableDialog.module.css +63 -0
- package/dist/esm/ui/RadioTabs.js +13 -0
- package/dist/esm/ui/RadioTabs.module.css +54 -0
- package/dist/esm/ui/Tabs.js +29 -0
- package/dist/esm/ui/Tabs.module.css +31 -0
- package/dist/esm/ui/Toast.js +64 -0
- package/dist/esm/ui/Toast.module.css +50 -0
- package/dist/esm/ui/Toaster.js +13 -0
- package/dist/esm/ui/Toaster.module.css +9 -0
- package/dist/esm/ui/test.js +14 -0
- package/dist/esm/utils/dom.mjs +6 -0
- package/dist/esm/utils/fs.mjs +94 -0
- package/dist/esm/utils/misc.mjs +15 -0
- package/dist/esm/utils/react.mjs +8 -0
- package/dist/esm/utils/rsync.mjs +57 -0
- package/dist/templates/project.json.hbs +5 -0
- package/dist/templates/projects/code/page.tsx.hbs +23 -0
- package/dist/templates/projects/code/src/assets.ts.hbs +9 -0
- package/dist/templates/projects/code/src/client.tsx.hbs +22 -0
- package/dist/templates/projects/code/src/helpers.ts.hbs +21 -0
- package/dist/templates/projects/code/src/highlights.ts.hbs +3 -0
- package/dist/templates/projects/code/src/markers.ts.hbs +13 -0
- package/dist/templates/projects/code/src/project.ts.hbs +23 -0
- package/dist/templates/projects/code/template.json +3 -0
- package/dist/templates/projects/default/page.tsx.hbs +23 -0
- package/dist/templates/projects/default/src/assets.ts.hbs +9 -0
- package/dist/templates/projects/default/src/client.tsx.hbs +22 -0
- package/dist/templates/projects/default/src/helpers.ts.hbs +21 -0
- package/dist/templates/projects/default/src/highlights.ts.hbs +3 -0
- package/dist/templates/projects/default/src/markers.ts.hbs +13 -0
- package/dist/templates/projects/default/src/project.ts.hbs +23 -0
- package/dist/templates/projects/default/template.json +4 -0
- package/dist/templates/types.ts.hbs +20 -0
- package/dist/types/LiqvidDevToolsProvider.d.ts +12 -0
- package/dist/types/api/contract.d.mts +66 -0
- package/dist/types/api/project-meta.d.mts +1 -0
- package/dist/types/api/recording.d.mts +5 -0
- package/dist/types/api/root.d.mts +1 -0
- package/dist/types/api/static-file.d.mts +6 -0
- package/dist/types/api/types.d.mts +1 -0
- package/dist/types/client.d.mts +43 -0
- package/dist/types/conventions.d.mts +3 -0
- package/dist/types/index.d.mts +19 -0
- package/dist/types/initialize.d.mts +14 -0
- package/dist/types/jobs/watch-assets.d.mts +18 -0
- package/dist/types/jobs/watch-project-files.d.mts +4 -0
- package/dist/types/next/api.d.mts +14 -0
- package/dist/types/next/page.d.ts +4 -0
- package/dist/types/pages/NewProjectButton.d.ts +1 -0
- package/dist/types/pages/RebuildButton.d.ts +1 -0
- package/dist/types/pages/root-actions.d.ts +29 -0
- package/dist/types/pages/root.d.ts +1 -0
- package/dist/types/providers/hosting/github-pages.d.mts +11 -0
- package/dist/types/providers/hosting/liqvid-studio.d.mts +10 -0
- package/dist/types/providers/hosting/s3.d.mts +11 -0
- package/dist/types/providers/hosting/sftp.d.mts +13 -0
- package/dist/types/providers/index.d.mts +10 -0
- package/dist/types/providers/social/bluesky.d.mts +10 -0
- package/dist/types/providers/social/facebook.d.mts +10 -0
- package/dist/types/providers/social/instagram.d.mts +10 -0
- package/dist/types/providers/social/twitter.d.mts +9 -0
- package/dist/types/providers/social/youtube.d.mts +9 -0
- package/dist/types/providers/types.d.mts +9 -0
- package/dist/types/publish.d.mts +1 -0
- package/dist/types/recording/RecordingControl.d.ts +16 -0
- package/dist/types/recording/RecordingDialog.d.ts +10 -0
- package/dist/types/schemas/liqvid-config.d.mts +51 -0
- package/dist/types/schemas/project.d.mts +51 -0
- package/dist/types/schemas/recording-meta.d.mts +17 -0
- package/dist/types/types/assets.d.mts +3 -0
- package/dist/types/types.d.mts +20 -0
- package/dist/types/ui/Dialog.d.ts +31 -0
- package/dist/types/ui/DockableDialog.d.ts +25 -0
- package/dist/types/ui/RadioTabs.d.ts +18 -0
- package/dist/types/ui/Tabs.d.ts +9 -0
- package/dist/types/ui/Toast.d.ts +23 -0
- package/dist/types/ui/Toaster.d.ts +6 -0
- package/dist/types/ui/test.d.ts +9 -0
- package/dist/types/utils/dom.d.mts +3 -0
- package/dist/types/utils/fs.d.mts +32 -0
- package/dist/types/utils/misc.d.mts +4 -0
- package/dist/types/utils/react.d.mts +5 -0
- package/dist/types/utils/rsync.d.mts +10 -0
- package/package.json +94 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Yuri Sulyma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Duration } from "@liqvid/duration";
|
|
4
|
+
import { RecordingProvider } from "@liqvid/recording";
|
|
5
|
+
import { LiqvidStudioPluginApiProvider, } from "@liqvid/studio-plugin-api";
|
|
6
|
+
import { createContext, useContext, useMemo, useState } from "react";
|
|
7
|
+
import { setProjectMeta } from "./client.mjs";
|
|
8
|
+
import { Toaster } from "./ui/Toaster";
|
|
9
|
+
import "./palette.css";
|
|
10
|
+
export const ProjectContext = createContext({
|
|
11
|
+
projectPath: "",
|
|
12
|
+
});
|
|
13
|
+
export function useProjectContext() {
|
|
14
|
+
return useContext(ProjectContext);
|
|
15
|
+
}
|
|
16
|
+
export function LiqvidDevToolsProvider({ children, plugins, projectPath, }) {
|
|
17
|
+
const projectContext = useMemo(() => ({ projectPath }), [projectPath]);
|
|
18
|
+
const [toasts, setToasts] = useState([]);
|
|
19
|
+
const api = useMemo(() => ({
|
|
20
|
+
makeToast(toast) {
|
|
21
|
+
setToasts((prev) => [...prev, { ...toast, time: Date.now() }]);
|
|
22
|
+
},
|
|
23
|
+
setDuration: (duration) => {
|
|
24
|
+
setProjectMeta({
|
|
25
|
+
body: {
|
|
26
|
+
durationMs: Duration.from(duration).inMilliseconds(),
|
|
27
|
+
},
|
|
28
|
+
search: {
|
|
29
|
+
url: projectPath,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
}), [projectPath]);
|
|
34
|
+
const recordingPlugins = useMemo(() => (plugins ?? []).reduce((acc, plugin) => {
|
|
35
|
+
if ("recorder" in plugin) {
|
|
36
|
+
acc.push(plugin);
|
|
37
|
+
}
|
|
38
|
+
return acc;
|
|
39
|
+
}, []), [plugins]);
|
|
40
|
+
return (_jsx(LiqvidStudioPluginApiProvider, { plugins: plugins, value: api, children: _jsxs(RecordingProvider, { plugins: recordingPlugins, children: [_jsxs(ProjectContext.Provider, { value: projectContext, children: [plugins?.map((plugin) => {
|
|
41
|
+
if (!plugin.useConfigurePlugin)
|
|
42
|
+
return null;
|
|
43
|
+
return (_jsx(CallHook, { usePlugin: plugin.useConfigurePlugin }, plugin.package));
|
|
44
|
+
}), children] }), _jsx(Toaster, { toasts })] }) }));
|
|
45
|
+
}
|
|
46
|
+
function CallHook({ usePlugin }) {
|
|
47
|
+
usePlugin();
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { RecordingMeta } from "../schemas/recording-meta.mjs";
|
|
3
|
+
export const listRecordingsOperation = {
|
|
4
|
+
endpoint: "/recordings",
|
|
5
|
+
response: z.array(RecordingMeta),
|
|
6
|
+
search: z.object({
|
|
7
|
+
url: z.string(),
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
export const setProjectMetaOperation = {
|
|
11
|
+
body: z.object({
|
|
12
|
+
durationMs: z.number(),
|
|
13
|
+
}),
|
|
14
|
+
endpoint: "/project-meta",
|
|
15
|
+
method: "POST",
|
|
16
|
+
search: z.object({
|
|
17
|
+
url: z.string(),
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Schema for plugin recording data.
|
|
22
|
+
* Data can be a Blob (for media) which requires a filename,
|
|
23
|
+
* or any JSON-serializable data.
|
|
24
|
+
*/
|
|
25
|
+
export const PluginRecordingData = z.object({
|
|
26
|
+
data: z.unknown(),
|
|
27
|
+
/** For Blob data, the filename to save as */
|
|
28
|
+
filename: z.string().optional(),
|
|
29
|
+
key: z.string(),
|
|
30
|
+
});
|
|
31
|
+
export const saveRecordingOperation = {
|
|
32
|
+
endpoint: "/recordings",
|
|
33
|
+
method: "POST",
|
|
34
|
+
search: z.object({
|
|
35
|
+
url: z.string(),
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Serve static files from the app directory.
|
|
40
|
+
* The `url` param is the path relative to the app directory.
|
|
41
|
+
* Example: /api/liqvid/static?url=/projects/my-video/.liqvid/recordings/test/@liqvid.media/audio.webm
|
|
42
|
+
*/
|
|
43
|
+
export const staticFileOperation = {
|
|
44
|
+
endpoint: "/static",
|
|
45
|
+
search: z.object({
|
|
46
|
+
url: z.string(),
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { safeGet } from "have-fun";
|
|
6
|
+
import { StatusCodes } from "http-status-codes";
|
|
7
|
+
import { PROJECT_META_FILE } from "../conventions.mjs";
|
|
8
|
+
import { setProjectMetaOperation } from "./contract.mjs";
|
|
9
|
+
export async function setProjectMeta(searchParams, rawBody) {
|
|
10
|
+
const $url = safeGet(searchParams, "url");
|
|
11
|
+
if ($url.isNone) {
|
|
12
|
+
return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
|
|
13
|
+
}
|
|
14
|
+
const $body = setProjectMetaOperation.body.safeParse(rawBody);
|
|
15
|
+
if (!$body.success) {
|
|
16
|
+
return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
|
|
17
|
+
}
|
|
18
|
+
const body = $body.data;
|
|
19
|
+
let projectPath = fileURLToPath($url.unwrap());
|
|
20
|
+
if (projectPath.endsWith("page.tsx")) {
|
|
21
|
+
projectPath = path.dirname(projectPath);
|
|
22
|
+
}
|
|
23
|
+
const assetsDir = path.join(projectPath, ".liqvid");
|
|
24
|
+
const projectMetaFile = path.join(assetsDir, PROJECT_META_FILE);
|
|
25
|
+
// error if assets dir doesn't exist
|
|
26
|
+
if (!fs.existsSync(assetsDir)) {
|
|
27
|
+
return Response.json(null, { status: StatusCodes.INTERNAL_SERVER_ERROR });
|
|
28
|
+
}
|
|
29
|
+
await fsp.writeFile(projectMetaFile, JSON.stringify({ duration: { milliseconds: body.durationMs } }, null, 2));
|
|
30
|
+
return new Response(null, {
|
|
31
|
+
status: StatusCodes.NO_CONTENT,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { compare } from "@liqvid/utils";
|
|
6
|
+
import { safeGet } from "have-fun";
|
|
7
|
+
import { StatusCodes } from "http-status-codes";
|
|
8
|
+
import { RECORDING_META_FILE } from "../conventions.mjs";
|
|
9
|
+
import { RecordingMetaFile as RecordingMetaFileSchema } from "../schemas/recording-meta.mjs";
|
|
10
|
+
import { loadJson } from "../utils/fs.mjs";
|
|
11
|
+
export async function listRecordings(searchParams) {
|
|
12
|
+
const $url = safeGet(searchParams, "url");
|
|
13
|
+
if ($url.isNone) {
|
|
14
|
+
return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
|
|
15
|
+
}
|
|
16
|
+
let projectDir = fileURLToPath($url.unwrap());
|
|
17
|
+
if (projectDir.endsWith("page.tsx")) {
|
|
18
|
+
projectDir = path.dirname(projectDir);
|
|
19
|
+
}
|
|
20
|
+
const assetsDir = path.join(projectDir, ".liqvid");
|
|
21
|
+
// error if assets dir doesn't exist
|
|
22
|
+
if (!fs.existsSync(assetsDir)) {
|
|
23
|
+
return Response.json(null, { status: StatusCodes.INTERNAL_SERVER_ERROR });
|
|
24
|
+
}
|
|
25
|
+
const recordingsDir = path.join(assetsDir, "recordings");
|
|
26
|
+
if (!fs.existsSync(recordingsDir)) {
|
|
27
|
+
return Response.json([]);
|
|
28
|
+
}
|
|
29
|
+
const recordingNames = await fsp.readdir(recordingsDir);
|
|
30
|
+
const $recordings = await Promise.all(recordingNames.map(async (name) => {
|
|
31
|
+
const dir = path.join(recordingsDir, name);
|
|
32
|
+
const recordingMeta = await loadJson(RecordingMetaFileSchema, path.join(dir, RECORDING_META_FILE));
|
|
33
|
+
const children = await fsp.readdir(dir);
|
|
34
|
+
return recordingMeta.map((file) => ({
|
|
35
|
+
...file,
|
|
36
|
+
name,
|
|
37
|
+
plugins: children.filter((x) => x !== RECORDING_META_FILE),
|
|
38
|
+
}));
|
|
39
|
+
}));
|
|
40
|
+
const recordings = $recordings.reduce((acc, $curr) => {
|
|
41
|
+
if ($curr.isErr)
|
|
42
|
+
return acc;
|
|
43
|
+
acc.push($curr.unwrap());
|
|
44
|
+
return acc;
|
|
45
|
+
}, []);
|
|
46
|
+
recordings.sort((a, b) => compare(a.created, b.created));
|
|
47
|
+
return Response.json(recordings);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Convert package name to directory name (replace / with .)
|
|
51
|
+
*/
|
|
52
|
+
function packageToDir(packageName) {
|
|
53
|
+
return packageName.replace(/\//g, ".");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Save a new recording to disk.
|
|
57
|
+
*/
|
|
58
|
+
export async function saveRecording(searchParams, formData) {
|
|
59
|
+
const $url = safeGet(searchParams, "url");
|
|
60
|
+
if ($url.isNone) {
|
|
61
|
+
return Response.json({ error: "invalid" }, { status: StatusCodes.BAD_REQUEST });
|
|
62
|
+
}
|
|
63
|
+
// Parse metadata
|
|
64
|
+
const metadataStr = formData.get("metadata");
|
|
65
|
+
if (typeof metadataStr !== "string") {
|
|
66
|
+
return Response.json({ error: "missing metadata" }, { status: StatusCodes.BAD_REQUEST });
|
|
67
|
+
}
|
|
68
|
+
let metadata;
|
|
69
|
+
try {
|
|
70
|
+
metadata = JSON.parse(metadataStr);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return Response.json({ error: "invalid metadata" }, { status: StatusCodes.BAD_REQUEST });
|
|
74
|
+
}
|
|
75
|
+
let projectDir = fileURLToPath($url.unwrap());
|
|
76
|
+
if (projectDir.endsWith("page.tsx")) {
|
|
77
|
+
projectDir = path.dirname(projectDir);
|
|
78
|
+
}
|
|
79
|
+
const assetsDir = path.join(projectDir, ".liqvid");
|
|
80
|
+
// Create assets dir if it doesn't exist
|
|
81
|
+
if (!fs.existsSync(assetsDir)) {
|
|
82
|
+
await fsp.mkdir(assetsDir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
const recordingsDir = path.join(assetsDir, "recordings");
|
|
85
|
+
if (!fs.existsSync(recordingsDir)) {
|
|
86
|
+
await fsp.mkdir(recordingsDir, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
// Create recording directory with ISO datetime name
|
|
89
|
+
const recordingName = new Date().toISOString().replace(/[:.]/g, "-");
|
|
90
|
+
const recordingDir = path.join(recordingsDir, recordingName);
|
|
91
|
+
await fsp.mkdir(recordingDir, { recursive: true });
|
|
92
|
+
// Write recording-meta.json
|
|
93
|
+
const recordingMeta = {
|
|
94
|
+
created: new Date().toISOString(),
|
|
95
|
+
duration: {
|
|
96
|
+
milliseconds: metadata.durationMs,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
await fsp.writeFile(path.join(recordingDir, RECORDING_META_FILE), JSON.stringify(recordingMeta, null, "\t"));
|
|
100
|
+
// Write plugin data
|
|
101
|
+
for (const pluginInfo of metadata.plugins) {
|
|
102
|
+
const pluginDir = path.join(recordingDir, packageToDir(pluginInfo.key));
|
|
103
|
+
await fsp.mkdir(pluginDir, { recursive: true });
|
|
104
|
+
const data = formData.get(pluginInfo.key);
|
|
105
|
+
if (data === null)
|
|
106
|
+
continue;
|
|
107
|
+
if (pluginInfo.isBlob) {
|
|
108
|
+
// Write blob data with specified filename
|
|
109
|
+
// In Node.js/Next.js, the data comes as a File/Blob-like object with arrayBuffer() method
|
|
110
|
+
const filename = pluginInfo.filename ?? "data.bin";
|
|
111
|
+
const blobData = data;
|
|
112
|
+
const buffer = Buffer.from(await blobData.arrayBuffer());
|
|
113
|
+
await fsp.writeFile(path.join(pluginDir, filename), buffer);
|
|
114
|
+
}
|
|
115
|
+
else if (typeof data === "string") {
|
|
116
|
+
// Write JSON data as raw.json
|
|
117
|
+
await fsp.writeFile(path.join(pluginDir, "raw.json"), data);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Run post-processing plugins
|
|
121
|
+
await runPostProcessing(recordingDir, metadata.plugins);
|
|
122
|
+
return new Response(null, { status: StatusCodes.CREATED });
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Attempt to discover and run post-processing plugins.
|
|
126
|
+
*/
|
|
127
|
+
async function runPostProcessing(recordingDir, plugins) {
|
|
128
|
+
// @ts-expect-error this file is provided by the client
|
|
129
|
+
const dynamicImports = (await import("@/.dynamic-imports")).default;
|
|
130
|
+
for (const pluginInfo of plugins) {
|
|
131
|
+
const pluginDir = path.join(recordingDir, packageToDir(pluginInfo.key));
|
|
132
|
+
const dynamicImporter = dynamicImports[`${pluginInfo.key}/liqvid-studio-server-plugin`];
|
|
133
|
+
// no server plugin
|
|
134
|
+
if (!dynamicImporter) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
let serverPlugin;
|
|
138
|
+
try {
|
|
139
|
+
// Try to import the server plugin
|
|
140
|
+
serverPlugin = await dynamicImporter();
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
console.error(`error loading server plugin for ${pluginInfo.key}`, e);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const plugin = serverPlugin.default ?? serverPlugin;
|
|
147
|
+
if (!plugin.postProcessRecording)
|
|
148
|
+
continue;
|
|
149
|
+
try {
|
|
150
|
+
await plugin.postProcessRecording({ dirname: pluginDir });
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
console.error(`error in ${pluginInfo.key}`, e);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { safeGet } from "have-fun";
|
|
5
|
+
import { StatusCodes } from "http-status-codes";
|
|
6
|
+
/**
|
|
7
|
+
* MIME type mappings for common file extensions
|
|
8
|
+
*/
|
|
9
|
+
const MIME_TYPES = {
|
|
10
|
+
".css": "text/css",
|
|
11
|
+
".gif": "image/gif",
|
|
12
|
+
".html": "text/html",
|
|
13
|
+
".jpeg": "image/jpeg",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".js": "application/javascript",
|
|
16
|
+
".json": "application/json",
|
|
17
|
+
".mjs": "application/javascript",
|
|
18
|
+
".mp3": "audio/mpeg",
|
|
19
|
+
".mp4": "video/mp4",
|
|
20
|
+
".ogg": "audio/ogg",
|
|
21
|
+
".pdf": "application/pdf",
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".svg": "image/svg+xml",
|
|
24
|
+
".ts": "text/typescript",
|
|
25
|
+
".tsx": "text/typescript",
|
|
26
|
+
".txt": "text/plain",
|
|
27
|
+
".wav": "audio/wav",
|
|
28
|
+
".webm": "audio/webm",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
".woff": "font/woff",
|
|
31
|
+
".woff2": "font/woff2",
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Get MIME type from file extension
|
|
35
|
+
*/
|
|
36
|
+
function getMimeType(filePath) {
|
|
37
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
38
|
+
return MIME_TYPES[ext] ?? "application/octet-stream";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Serve static files from the app directory.
|
|
42
|
+
* The `url` param is the path relative to the app directory.
|
|
43
|
+
* Example: /api/liqvid/static?url=/projects/my-video/.liqvid/recordings/test/@liqvid.media/audio.webm
|
|
44
|
+
*/
|
|
45
|
+
export async function serveStaticFile(searchParams) {
|
|
46
|
+
const $url = safeGet(searchParams, "url");
|
|
47
|
+
if ($url.isNone) {
|
|
48
|
+
return Response.json({ error: "missing url parameter" }, { status: StatusCodes.BAD_REQUEST });
|
|
49
|
+
}
|
|
50
|
+
const requestedPath = $url.unwrap();
|
|
51
|
+
// Security: Prevent directory traversal attacks
|
|
52
|
+
const normalizedPath = path.normalize(requestedPath);
|
|
53
|
+
if (normalizedPath.includes("..")) {
|
|
54
|
+
return Response.json({ error: "invalid path" }, { status: StatusCodes.BAD_REQUEST });
|
|
55
|
+
}
|
|
56
|
+
// Resolve relative to the app directory
|
|
57
|
+
const appDir = path.join(process.cwd(), "app");
|
|
58
|
+
const absolutePath = path.join(appDir, normalizedPath);
|
|
59
|
+
// Security: Ensure the resolved path is within the app directory
|
|
60
|
+
if (!absolutePath.startsWith(appDir + path.sep)) {
|
|
61
|
+
return Response.json({ error: "invalid path" }, { status: StatusCodes.BAD_REQUEST });
|
|
62
|
+
}
|
|
63
|
+
// Check if file exists
|
|
64
|
+
if (!fs.existsSync(absolutePath)) {
|
|
65
|
+
return Response.json({ error: "file not found" }, { status: StatusCodes.NOT_FOUND });
|
|
66
|
+
}
|
|
67
|
+
// Check if it's a file (not a directory)
|
|
68
|
+
const stat = await fsp.stat(absolutePath);
|
|
69
|
+
if (!stat.isFile()) {
|
|
70
|
+
return Response.json({ error: "not a file" }, { status: StatusCodes.BAD_REQUEST });
|
|
71
|
+
}
|
|
72
|
+
// Read and serve the file
|
|
73
|
+
const content = await fsp.readFile(absolutePath);
|
|
74
|
+
const mimeType = getMimeType(absolutePath);
|
|
75
|
+
return new Response(content, {
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Length": String(content.length),
|
|
78
|
+
"Content-Type": mimeType,
|
|
79
|
+
},
|
|
80
|
+
status: StatusCodes.OK,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** biome-ignore-all lint/complexity/noBannedTypes: intersection types */
|
|
2
|
+
/** biome-ignore-all lint/suspicious/noExplicitAny: heavy type magic here */
|
|
3
|
+
import { Err, Ok } from "have-fun";
|
|
4
|
+
import { listRecordingsOperation, saveRecordingOperation, setProjectMetaOperation, } from "./api/contract.mjs";
|
|
5
|
+
import { fetchJson } from "./utils/dom.mjs";
|
|
6
|
+
const apiRoot = "/api/liqvid";
|
|
7
|
+
function makeFetcher(config) {
|
|
8
|
+
const { endpoint, method = "GET", response: responseModel, search: searchModel, } = config;
|
|
9
|
+
const init = { method };
|
|
10
|
+
if (responseModel) {
|
|
11
|
+
return async (opts) => {
|
|
12
|
+
const queryString = searchModel
|
|
13
|
+
? "?" + new URLSearchParams(opts.search)
|
|
14
|
+
: "";
|
|
15
|
+
const url = apiRoot + endpoint + queryString;
|
|
16
|
+
if (method === "POST" && opts.body) {
|
|
17
|
+
init.body = JSON.stringify(opts.body);
|
|
18
|
+
}
|
|
19
|
+
const $res = await fetchJson(responseModel, url, init);
|
|
20
|
+
return $res;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return async (opts) => {
|
|
24
|
+
const queryString = searchModel
|
|
25
|
+
? "?" + new URLSearchParams(opts.search)
|
|
26
|
+
: "";
|
|
27
|
+
const url = apiRoot + endpoint + queryString;
|
|
28
|
+
if (method === "POST" && opts.body) {
|
|
29
|
+
init.body = JSON.stringify(opts.body);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url, init);
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
return Ok(undefined);
|
|
35
|
+
}
|
|
36
|
+
return Err(res.json());
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return Err(e);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export const listRecordings = makeFetcher(listRecordingsOperation);
|
|
44
|
+
export const setProjectMeta = makeFetcher(setProjectMetaOperation);
|
|
45
|
+
/**
|
|
46
|
+
* Determine filename for a Blob based on its MIME type.
|
|
47
|
+
*/
|
|
48
|
+
function getBlobFilename(blob) {
|
|
49
|
+
if (blob.type.startsWith("video/")) {
|
|
50
|
+
return "video.webm";
|
|
51
|
+
}
|
|
52
|
+
if (blob.type.startsWith("audio/")) {
|
|
53
|
+
return "audio.webm";
|
|
54
|
+
}
|
|
55
|
+
return "data.bin";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Save recording data to the server.
|
|
59
|
+
* Handles both JSON data and Blob data (for media recordings).
|
|
60
|
+
*/
|
|
61
|
+
export async function saveRecording(opts) {
|
|
62
|
+
const { search, body } = opts;
|
|
63
|
+
const queryString = "?" + new URLSearchParams(search);
|
|
64
|
+
const url = apiRoot + saveRecordingOperation.endpoint + queryString;
|
|
65
|
+
const formData = new FormData();
|
|
66
|
+
// Add metadata
|
|
67
|
+
const metadata = {
|
|
68
|
+
durationMs: body.durationMs,
|
|
69
|
+
plugins: body.plugins.map(({ key, data }) => ({
|
|
70
|
+
filename: data instanceof Blob ? getBlobFilename(data) : undefined,
|
|
71
|
+
isBlob: data instanceof Blob,
|
|
72
|
+
key,
|
|
73
|
+
})),
|
|
74
|
+
};
|
|
75
|
+
formData.append("metadata", JSON.stringify(metadata));
|
|
76
|
+
// Add plugin data
|
|
77
|
+
for (const { key, data } of body.plugins) {
|
|
78
|
+
if (data instanceof Blob) {
|
|
79
|
+
formData.append(key, data, getBlobFilename(data));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
formData.append(key, JSON.stringify(data));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(url, {
|
|
87
|
+
body: formData,
|
|
88
|
+
method: "POST",
|
|
89
|
+
});
|
|
90
|
+
if (res.ok) {
|
|
91
|
+
return Ok(undefined);
|
|
92
|
+
}
|
|
93
|
+
return Err(new TypeError(await res.text()));
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return Err(e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { DockableDialog } from "./ui/DockableDialog";
|
|
2
|
+
export * from "./ui/Tabs";
|
|
3
|
+
import { devComponent, devProvider } from "@liqvid/ssr/react";
|
|
4
|
+
export { LiqvidDevToolsProvider as LiqvidDevToolsProviderProd, useProjectContext, } from "./LiqvidDevToolsProvider";
|
|
5
|
+
export { RecordingControl as RecordingControlProd, } from "./recording/RecordingControl";
|
|
6
|
+
/* ------------------------- dev-only exports ------------------------- */
|
|
7
|
+
/**
|
|
8
|
+
* Liqvid dev tools provider.
|
|
9
|
+
*
|
|
10
|
+
* Only operates in development. If you want this in production,
|
|
11
|
+
* use {@link LiqvidDevToolsProviderProd} instead.
|
|
12
|
+
*/
|
|
13
|
+
export const LiqvidDevToolsProvider = devProvider(() => import("./LiqvidDevToolsProvider").then((imports) => imports.LiqvidDevToolsProvider));
|
|
14
|
+
/**
|
|
15
|
+
* Liqvid recording control.
|
|
16
|
+
*
|
|
17
|
+
* Only renders in development. If you want this in production,
|
|
18
|
+
* use {@link RecordingControlProd} instead.
|
|
19
|
+
*/
|
|
20
|
+
export const RecordingControl = devComponent(() => import("./recording/RecordingControl").then((imports) => imports.RecordingControl));
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { runNextBuild } from "@liqvid/cli/build";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import { watchAssets } from "./jobs/watch-assets.mjs";
|
|
6
|
+
import { watchProjectFiles } from "./jobs/watch-project-files.mjs";
|
|
7
|
+
export { runNextBuild };
|
|
8
|
+
const symbol = Symbol.for("@liqvid/server");
|
|
9
|
+
const DEFAULT_PRODUCTION_SERVER_PORT = 4000;
|
|
10
|
+
export async function initializeServer() {
|
|
11
|
+
const state = getServerState();
|
|
12
|
+
const { jobs, projects } = state;
|
|
13
|
+
jobs.watchAssets ??= watchAssets();
|
|
14
|
+
jobs.watchProjectFiles ??= jobs.watchAssets.then(() => watchProjectFiles(projects));
|
|
15
|
+
jobs.productionServer ??= startProductionServer(state);
|
|
16
|
+
await jobs.watchProjectFiles;
|
|
17
|
+
}
|
|
18
|
+
async function startProductionServer(state) {
|
|
19
|
+
const outDir = path.join(process.cwd(), "out");
|
|
20
|
+
// Check if 'out' directory exists, if not run 'next build'
|
|
21
|
+
if (!fs.existsSync(outDir)) {
|
|
22
|
+
console.log("'out' directory not found, running 'next build'...");
|
|
23
|
+
await runNextBuild();
|
|
24
|
+
}
|
|
25
|
+
// Start the production server
|
|
26
|
+
const port = Number(process.env.LIQVID_PRODUCTION_SERVER_PORT) ||
|
|
27
|
+
DEFAULT_PRODUCTION_SERVER_PORT;
|
|
28
|
+
state.productionServerPort = port;
|
|
29
|
+
console.log(`Starting production server on port ${port}...`);
|
|
30
|
+
execa("npx", ["serve", "out", "-p", String(port)], {
|
|
31
|
+
cwd: process.cwd(),
|
|
32
|
+
env: { ...process.env, NODE_ENV: "production" },
|
|
33
|
+
stderr: "inherit",
|
|
34
|
+
stdout: "inherit",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function getServerState() {
|
|
38
|
+
if (!(symbol in globalThis)) {
|
|
39
|
+
globalThis[symbol] = {
|
|
40
|
+
jobs: {
|
|
41
|
+
productionServer: null,
|
|
42
|
+
watchAssets: null,
|
|
43
|
+
watchProjectFiles: null,
|
|
44
|
+
},
|
|
45
|
+
productionServerPort: DEFAULT_PRODUCTION_SERVER_PORT,
|
|
46
|
+
projects: {},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return globalThis[symbol];
|
|
50
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { execa } from "execa";
|
|
6
|
+
import Handlebars from "handlebars";
|
|
7
|
+
import { PROJECT_META_FILE } from "../conventions.mjs";
|
|
8
|
+
import { getBiomePath } from "../utils/fs.mjs";
|
|
9
|
+
import { debounce } from "../utils/misc.mjs";
|
|
10
|
+
export const ASSETS_DIRNAME = ".liqvid";
|
|
11
|
+
/** whether a file should be omitted from the directory listing */
|
|
12
|
+
function isForbidden(_filename, basename) {
|
|
13
|
+
if (basename === ".DS_Store")
|
|
14
|
+
return true;
|
|
15
|
+
if (basename === "types.ts")
|
|
16
|
+
return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
function shouldIgnore({ basename, filename, }) {
|
|
20
|
+
if (filename.endsWith("~"))
|
|
21
|
+
return true;
|
|
22
|
+
if (basename === ".DS_Store")
|
|
23
|
+
return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const TARGET_DIR = path.join(process.cwd(), "app");
|
|
27
|
+
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
|
|
28
|
+
export async function watchAssets() {
|
|
29
|
+
Handlebars.registerHelper("json", (obj) => {
|
|
30
|
+
return new Handlebars.SafeString(JSON.stringify(obj, null, 2));
|
|
31
|
+
});
|
|
32
|
+
fs.watch(TARGET_DIR, { recursive: true }, async (_eventName, relPath) => {
|
|
33
|
+
if (!relPath)
|
|
34
|
+
return;
|
|
35
|
+
const filename = path.join(TARGET_DIR, relPath);
|
|
36
|
+
const dirname = path.dirname(filename);
|
|
37
|
+
const basename = path.basename(filename);
|
|
38
|
+
if (shouldIgnore({ basename, filename }))
|
|
39
|
+
return;
|
|
40
|
+
// assets
|
|
41
|
+
const $_ = filename.match(/^.*\/\.liqvid(?=\/)/);
|
|
42
|
+
if (!$_)
|
|
43
|
+
return null;
|
|
44
|
+
if (basename === "types.ts" || basename === PROJECT_META_FILE)
|
|
45
|
+
return;
|
|
46
|
+
const assetsDir = $_[0];
|
|
47
|
+
const biomePath = await getBiomePath(dirname);
|
|
48
|
+
debounce(() => generateProjectTypes({ assetsDir, biomePath }), assetsDir);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Generate the types.ts file inside the assets dir.
|
|
53
|
+
*/
|
|
54
|
+
async function generateProjectTypes({ assetsDir, biomePath, }) {
|
|
55
|
+
const directoryStructure = await listDir(assetsDir);
|
|
56
|
+
runTemplate({
|
|
57
|
+
biomePath,
|
|
58
|
+
data: {
|
|
59
|
+
directoryStructure,
|
|
60
|
+
},
|
|
61
|
+
out: path.join(assetsDir, "types.ts"),
|
|
62
|
+
template: "types.ts.hbs",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Generate a file from a Handlebars template, and format the result with Biome (if available).
|
|
67
|
+
*/
|
|
68
|
+
export async function runTemplate({ biomePath, data, out, template, }) {
|
|
69
|
+
console.debug(`compiling ${template} -> ${out}`);
|
|
70
|
+
const templateHbs = await fsp.readFile(path.join(TEMPLATES_DIR, template), "utf8");
|
|
71
|
+
try {
|
|
72
|
+
const template = Handlebars.compile(templateHbs);
|
|
73
|
+
const result = template(data);
|
|
74
|
+
await fsp.writeFile(out, result);
|
|
75
|
+
// invoke biome
|
|
76
|
+
if (biomePath.isSome) {
|
|
77
|
+
await execa(biomePath.unwrap(), ["check", "--fix", out]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
console.error(e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function listDir(dirname) {
|
|
85
|
+
const dir = await fsp.readdir(dirname);
|
|
86
|
+
return Object.fromEntries(await Promise.all(dir.reduce((acc, basename) => {
|
|
87
|
+
const filename = path.join(dirname, basename);
|
|
88
|
+
if (isForbidden(filename, basename))
|
|
89
|
+
return acc;
|
|
90
|
+
const stats = fs.statSync(filename);
|
|
91
|
+
if (stats.isDirectory()) {
|
|
92
|
+
acc.push(listDir(filename).then((result) => [basename, result]));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
acc.push(Promise.resolve([basename, null]));
|
|
96
|
+
}
|
|
97
|
+
return acc;
|
|
98
|
+
}, [])));
|
|
99
|
+
}
|