├── .gitignore ├── README.md ├── build.mjs ├── lerna.json ├── package.json ├── packages ├── captioning │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── transcription.ts │ │ └── webvtt.ts │ └── tsconfig.json ├── cli │ ├── liqvid-cli.mjs │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── tasks │ │ │ ├── audio.ts │ │ │ ├── build.ts │ │ │ ├── config.ts │ │ │ ├── render.ts │ │ │ ├── serve.ts │ │ │ └── thumbs.ts │ └── tsconfig.json ├── gsap │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── yarn.lock ├── host │ ├── README.md │ ├── lv-host.js │ └── package.json ├── katex │ ├── .eslintrc.json │ ├── README.md │ ├── package.json │ ├── src │ │ ├── RenderGroup.ts │ │ ├── fancy.tsx │ │ ├── index.tsx │ │ ├── loading.ts │ │ └── plain.tsx │ └── tsconfig.json ├── keymap │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── mixedCaseVals.ts │ │ └── react.ts │ ├── tests │ │ └── keymap.test.ts │ └── tsconfig.json ├── magic │ ├── .eslintrc.json │ ├── LICENSE │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── default-assets.ts │ │ ├── index.ts │ │ └── types.ts │ ├── tests │ │ └── magic.test.ts │ └── tsconfig.json ├── mathjax │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── build │ ├── build.ts │ ├── package.json │ ├── src │ │ ├── Blocking.tsx │ │ ├── NonBlocking.tsx │ │ ├── index.ts │ │ └── plain.tsx │ ├── test │ │ └── index.js │ └── tsconfig.json ├── playback │ ├── README.md │ ├── package.json │ ├── src │ │ ├── animation.ts │ │ ├── core.ts │ │ ├── index.ts │ │ └── react.ts │ └── tsconfig.json ├── polyfills │ ├── package.json │ ├── src │ │ ├── polyfills.ts │ │ └── waapi.js │ └── yarn.lock ├── react-three │ ├── .eslintrc.json │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.tsx │ └── tsconfig.json ├── react │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── three.tsx │ ├── tsconfig.json │ └── yarn.lock ├── renderer │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── tasks │ │ │ ├── convert.ts │ │ │ ├── join.ts │ │ │ ├── solidify.ts │ │ │ └── thumbs.ts │ │ ├── types.ts │ │ └── utils │ │ │ ├── binaries.ts │ │ │ ├── capture.ts │ │ │ ├── concurrency.ts │ │ │ ├── connect.ts │ │ │ ├── pool.ts │ │ │ └── stitch.ts │ ├── tsconfig.json │ └── yarn.lock ├── server │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── yarn.lock ├── utils │ ├── .eslintrc.json │ ├── LICENSE │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── animation.ts │ │ ├── interactivity.ts │ │ ├── json.ts │ │ ├── misc.ts │ │ ├── react.ts │ │ ├── replay-data.ts │ │ ├── svg.ts │ │ └── time.ts │ ├── tests │ │ ├── animation.test.ts │ │ ├── json.test.ts │ │ ├── misc.test.ts │ │ └── time.test.ts │ └── tsconfig.json └── xyjax │ ├── .eslintrc.json │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── src │ └── index.ts │ └── tsconfig.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | tsconfig.tsbuildinfo 5 | lerna-debug.log 6 | .DS_Store 7 | *.code-* 8 | *.sublime-* 9 | *.log 10 | 11 | packages/**/LICENSE 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository has been merged into https://github.com/liqvidjs/liqvid and is no longer used 2 | -------------------------------------------------------------------------------- /build.mjs: -------------------------------------------------------------------------------- 1 | /* Horrifying fixer-upper for ESM imports */ 2 | import {existsSync, promises as fsp, readFileSync} from "fs"; 3 | import * as path from "path"; 4 | 5 | const ESM = path.join(process.cwd(), "dist", "esm"); 6 | const NODE_MODULES = path.join(process.cwd(), "node_modules"); 7 | 8 | build(); 9 | 10 | async function build() { 11 | // rename files first 12 | await walkDir(ESM, async filename => { 13 | if (!filename.endsWith(".js")) 14 | return; 15 | await renameExtension(filename); 16 | }); 17 | // now fix imports 18 | await walkDir(ESM, async filename => { 19 | if (!filename.endsWith(".mjs")) 20 | return; 21 | await fixImports(filename); 22 | }); 23 | } 24 | 25 | /** 26 | * Recursively walk a directory 27 | * @param {string} dirname Name of directory to walk 28 | * @param {(filename: string) => Promise} callback Callback 29 | */ 30 | async function walkDir(dirname, callback) { 31 | const files = (await fsp.readdir(dirname)).map(filename => path.join(dirname, filename)); 32 | 33 | /* first rename all files */ 34 | 35 | await Promise.all(files.map(async filename => { 36 | const stat = await fsp.stat(filename); 37 | if (stat.isDirectory()) { 38 | return walkDir(filename, callback); 39 | } 40 | await callback(filename); 41 | })); 42 | } 43 | 44 | /** Add extensions to relative imports */ 45 | async function fixImports(filename) { 46 | let content = await fsp.readFile(filename, "utf8"); 47 | content = content.replaceAll(/^((?:ex|im)port .+? from\s+)(["'])(.+?)(\2;?)$/gm, (match, head, q, name, tail) => { 48 | // relative imports 49 | if (name.startsWith(".")) { 50 | // already has extension 51 | if (name.match(/\.[cm]?js$/)) { 52 | return match; 53 | } 54 | // figure out which file it's referring to 55 | const target = findExtension(path.dirname(filename), name); 56 | return (head + q + target + tail); 57 | } else { 58 | try { 59 | const json = JSON.parse(readFileSync(path.join(NODE_MODULES, getPackageName(name), "package.json"), "utf8")); 60 | if (json.exports) { 61 | 62 | } 63 | } catch (e) { 64 | 65 | } 66 | } 67 | return match; 68 | }); 69 | 70 | await fsp.writeFile(filename, content); 71 | } 72 | 73 | /** Find extension */ 74 | function findExtension(pathname, relative) { 75 | const filename = path.resolve(pathname, relative); 76 | for (const extn of ["mjs", "js", "cjs"]) { 77 | const full = filename + "." + extn; 78 | 79 | if (existsSync(full)) { 80 | let rewrite = path.relative(pathname, full); 81 | if (!rewrite.startsWith(".")) { 82 | rewrite = "./" + rewrite; 83 | } 84 | return rewrite; 85 | } 86 | } 87 | throw new Error(`Could not resolve ${filename}`); 88 | } 89 | 90 | /** Get name of NPM package */ 91 | function getPackageName(name) { 92 | const parts = name.split("/"); 93 | if (name.startsWith("@")) { 94 | return parts.slice(0, 2).join("/"); 95 | } 96 | return parts[0]; 97 | } 98 | 99 | /** Change file extension */ 100 | async function renameExtension(filename, extn = "mjs") { 101 | await fsp.rename(filename, filename.replace(/\.js$/, `.${extn}`)); 102 | } 103 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "independent" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "devDependencies": { 5 | "lerna": "^4.0.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/captioning/LICENSE: -------------------------------------------------------------------------------- 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. 10 | -------------------------------------------------------------------------------- /packages/captioning/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/captioning 2 | 3 | This package provides audio transcription and captioning utilities for [Liqvid](https://liqvidjs.org). It is used internally by [@liqvid/cli](../cli). 4 | -------------------------------------------------------------------------------- /packages/captioning/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/captioning", 3 | "version": "1.0.0", 4 | "description": "Audio transcription and captioning for Liqvid", 5 | "files": [ 6 | "dist/*" 7 | ], 8 | "main": "dist/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/liqvidjs/liqvid.git" 12 | }, 13 | "author": "Yuri Sulyma ", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/liqvidjs/liqvid/issues" 17 | }, 18 | "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/renderer#readme", 19 | "devDependencies": { 20 | "@types/node": "^14.14.37", 21 | "@typescript-eslint/eslint-plugin": "^4.11.1", 22 | "@typescript-eslint/parser": "^4.11.1", 23 | "eslint": "^7.16.0", 24 | "eslint-plugin-react": "^7.21.5", 25 | "eslint-plugin-react-hooks": "^4.2.0", 26 | "ibm-watson": "^6.2.1", 27 | "typescript": "^4.1.3" 28 | }, 29 | "peerDependencies": { 30 | "ibm-watson": "^6.2.1" 31 | }, 32 | "peerDependenciesMeta": { 33 | "ibm-watson": { 34 | "optional": true 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/captioning/src/index.ts: -------------------------------------------------------------------------------- 1 | export {transcribe} from "./transcription"; 2 | export {toWebVTT} from "./webvtt"; 3 | -------------------------------------------------------------------------------- /packages/captioning/src/transcription.ts: -------------------------------------------------------------------------------- 1 | import fs, {promises as fsp} from "fs"; 2 | import {IamAuthenticator} from "ibm-watson/auth"; 3 | import SpeechToTextV1 from "ibm-watson/speech-to-text/v1"; 4 | import path from "path"; 5 | import {toWebVTT} from "./webvtt"; 6 | 7 | /** 8 | * Transcript with per-word timings 9 | */ 10 | export type Transcript = [string, number, number][][]; 11 | 12 | /** 13 | * Transcribe audio file 14 | */ 15 | export async function transcribe(args: { 16 | /** Path to audio file */ 17 | input: string; 18 | 19 | /** Path for WebVTT captions */ 20 | captions: string; 21 | 22 | /** Params to pass to IBM Watson. */ 23 | params: Partial[0]>; 24 | 25 | /** Path for rich transcript */ 26 | transcript: string; 27 | 28 | /** IBM Cloud API key */ 29 | apiKey: string; 30 | 31 | /** IBM Watson endpoint URL */ 32 | apiUrl: string; 33 | }) { 34 | const filename = path.resolve(process.cwd(), args.input); 35 | const output = path.resolve(process.cwd(), args.transcript); 36 | 37 | const extn = path.extname(filename); 38 | 39 | // SpeechToText instance 40 | const speechToText = new SpeechToTextV1({ 41 | authenticator: new IamAuthenticator({ 42 | apikey: args.apiKey 43 | }), 44 | serviceUrl: args.apiUrl, 45 | }); 46 | 47 | const params = Object.assign({ 48 | audio: fs.createReadStream(filename), 49 | contentType: `audio/${extn.slice(1)}`, 50 | 51 | objectMode: true, 52 | model: "en-US_BroadbandModel", 53 | profanityFilter: false, 54 | smartFormatting: true, 55 | timestamps: true 56 | }, args.params); 57 | 58 | // transcribe 59 | const {result: json} = await speechToText.recognize(params); 60 | await fsp.writeFile(args.transcript, JSON.stringify(json, null, 2)); 61 | 62 | // format 63 | const blockSize = 8; 64 | const words = 65 | json.results 66 | .map(_ => _.alternatives[0].timestamps) 67 | .reduce((a, b) => a.concat(b), [] as [string, number, number][]) 68 | .map(([word, t1, t2]: [string, number, number]) => [word, Math.floor(t1 * 1000), Math.floor(t2 * 1000)] as [string, number, number]); 69 | 70 | const blocks: Transcript = []; 71 | 72 | for (let i = 0; i < words.length; i += blockSize) { 73 | blocks.push(words.slice(i, i+blockSize));; 74 | } 75 | 76 | // save new version 77 | let str = JSON.stringify(blocks, null, 2); 78 | str = str.replace(/(? " + formatTimeMs(line[line.length - 1][2])); 17 | captions.push(line.map(_ => _[0]).join(" ")); 18 | captions.push(""); 19 | } 20 | 21 | return captions.join("\n"); 22 | } 23 | 24 | /* WebVTT requires mm:ss whereas @liqvid/utils/time produces [m]m:ss */ 25 | function formatTime(time: number): string { 26 | if (time < 0) { 27 | return "-" + formatTime(-time); 28 | } 29 | const minutes = Math.floor(time / 60 / 1000), 30 | seconds = Math.floor(time / 1000 % 60); 31 | 32 | return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; 33 | } 34 | 35 | function formatTimeMs(time: number): string { 36 | if (time < 0) { 37 | return "-" + formatTimeMs(-time); 38 | } 39 | const milliseconds = Math.floor(time % 1000); 40 | 41 | return `${formatTime(time)}.${milliseconds.toString().padStart(3, "0")}`; 42 | } 43 | -------------------------------------------------------------------------------- /packages/captioning/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src" 7 | }, 8 | "include": ["./src"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/liqvid-cli.mjs: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import * as pkg from "./dist/index.js"; 3 | 4 | pkg.main() 5 | .then(() => process.exit(0)) 6 | .catch((err) => { 7 | // eslint-disable-next-line no-console 8 | console.error(err); 9 | process.exit(1); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/cli", 3 | "version": "1.0.3", 4 | "description": "Liqvid command line utility", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "liqvid": "liqvid-cli.mjs" 8 | }, 9 | "files": [ 10 | "dist/*", 11 | "liqvid-cli.mjs" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/liqvidjs/liqvid.git" 16 | }, 17 | "author": "Yuri Sulyma ", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/liqvidjs/liqvid/issues" 21 | }, 22 | "homepage": "https://github.com/liqvidjs/liqvid#readme", 23 | "devDependencies": { 24 | "@types/cli-progress": "^3.9.2", 25 | "@types/node": "^16.3.2", 26 | "@types/yargs": "^17.0.2", 27 | "typescript": "^4.3.5" 28 | }, 29 | "sideEffects": false, 30 | "dependencies": { 31 | "@liqvid/captioning": "^1.0.0", 32 | "@liqvid/renderer": "^1.0.2", 33 | "@liqvid/server": "^1.0.0", 34 | "@liqvid/utils": "^1.0.0", 35 | "cli-progress": "^3.9.0", 36 | "execa": "^5.1.1", 37 | "ts-node": "^10.4.0", 38 | "webpack": "^5.65.0", 39 | "yargs": "^17.0.1", 40 | "yargs-parser": "^20.2.9" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | import yargs from "yargs"; 2 | 3 | // shared options 4 | 5 | import {audio} from "./tasks/audio.js"; 6 | import {build} from "./tasks/build.js"; 7 | import {serve} from "./tasks/serve.js"; 8 | import {render} from "./tasks/render.js"; 9 | import {thumbs} from "./tasks/thumbs.js"; 10 | 11 | // entry 12 | export async function main() { 13 | let config = // WTF 14 | yargs 15 | .scriptName("liqvid") 16 | .strict() 17 | .usage("$0 [args]") 18 | .demandCommand(1, 'Must specify a command'); 19 | 20 | config = audio(config); 21 | config = build(config); 22 | config = serve(config); 23 | config = render(config); 24 | config = thumbs(config); 25 | 26 | // version 27 | config.version(require("../package.json").version); 28 | 29 | return config.help().argv; 30 | } 31 | 32 | import type {createServer} from "@liqvid/server"; 33 | import type {solidify, thumbs as captureThumbs} from "@liqvid/renderer"; 34 | import type {buildProject} from "./tasks/build"; 35 | import type {transcribe} from "@liqvid/captioning"; 36 | 37 | /** 38 | * Configuration object 39 | */ 40 | export interface LiqvidConfig { 41 | audio: { 42 | transcribe: Partial[0]>; 43 | } 44 | build: Partial[0]>; 45 | render: Partial[0]>; 46 | serve: Partial[0]>; 47 | thumbs: Partial[0]>; 48 | } 49 | -------------------------------------------------------------------------------- /packages/cli/src/tasks/audio.ts: -------------------------------------------------------------------------------- 1 | import {transcribe} from "@liqvid/captioning"; 2 | import {convert, join} from "@liqvid/renderer"; 3 | import path from "path"; 4 | import type Yargs from "yargs"; 5 | import {DEFAULT_CONFIG, parseConfig} from "./config.js"; 6 | 7 | /** 8 | * Audio utilities 9 | */ 10 | export const audio = (yargs: typeof Yargs) => 11 | yargs 12 | .command("audio", "Audio helpers", (yargs) => { 13 | return (yargs 14 | // convert command 15 | .command("convert ", "Repair and convert webm recordings", () => {}, convert) 16 | // join command 17 | .command("join [filenames..]", "Join audio files into a single file", yargs => { 18 | yargs 19 | .positional("filenames", { 20 | desc: "Filenames to join", 21 | coerce: (filenames: string[]) => filenames ? filenames.map(_ => path.resolve(_)) : [] 22 | }) 23 | .option("output", { 24 | alias: "o", 25 | desc: "Output file. If not specified, defaults to last input filename.", 26 | coerce: (output?: string) => output ? path.resolve(output) : output 27 | }) 28 | }, join) 29 | // transcribe command 30 | .command("transcribe", "Transcribe audio", (yargs) => { 31 | yargs 32 | .config("config", parseConfig("audio", "transcribe")) 33 | .default("config", DEFAULT_CONFIG) 34 | .option("api-key", { 35 | desc: "IBM API key", 36 | demandOption: true 37 | }) 38 | .option("api-url", { 39 | desc: "IBM Watson endpoint URL", 40 | demandOption: true 41 | }) 42 | .option("input", { 43 | alias: "i", 44 | desc: "Audio filename", 45 | normalize: true, 46 | demandOption: true 47 | }) 48 | .option("captions", { 49 | alias: "c", 50 | default: "./captions.vtt", 51 | desc: "Captions input filename", 52 | normalize: true 53 | }) 54 | .option("transcript", { 55 | alias: "t", 56 | default: "./transcript.json", 57 | desc: "Rich transcript filename", 58 | normalize: true 59 | }) 60 | .option("params", { 61 | desc: "Parameters for IBM Watson", 62 | default: {} 63 | }) 64 | }, transcribe) 65 | .demandCommand(1, 'Must specify an audio command') 66 | ); 67 | }) 68 | -------------------------------------------------------------------------------- /packages/cli/src/tasks/build.ts: -------------------------------------------------------------------------------- 1 | import {promises as fsp} from "fs"; 2 | import fs from "fs"; 3 | import type Yargs from "yargs"; 4 | import path from "path"; 5 | 6 | import {parseConfig, DEFAULT_CONFIG} from "./config.js"; 7 | import webpack from "webpack"; 8 | import {transform, scripts as defaultScripts, styles as defaultStyles, ScriptData, StyleData} from "@liqvid/magic"; 9 | 10 | /** 11 | * Build project 12 | */ 13 | export const build = (yargs: typeof Yargs) => 14 | yargs 15 | .command("build", "Build project", (yargs) => { 16 | return (yargs 17 | .config("config", parseConfig("build")) 18 | .default("config", DEFAULT_CONFIG) 19 | .option("clean", { 20 | alias: "C", 21 | default: false, 22 | desc: "Delete old dist directory before starting", 23 | type: "boolean" 24 | }) 25 | .option("out", { 26 | alias: "o", 27 | coerce: path.resolve, 28 | desc: "Output directory", 29 | default: "./dist", 30 | normalize: true 31 | }) 32 | .option("static", { 33 | alias: "s", 34 | coerce: path.resolve, 35 | desc: "Static directory", 36 | default: "./static" 37 | }) 38 | .option("scripts", { 39 | coerce: coerceScripts, 40 | desc: "Script aliases", 41 | default: {} 42 | }) 43 | .option("styles", { 44 | desc: "Style aliases", 45 | default: {} 46 | }) 47 | ); 48 | }, (args) => { 49 | return buildProject(args); 50 | }) 51 | 52 | export async function buildProject(config: { 53 | /** Clean build directory */ 54 | clean: boolean; 55 | 56 | /** Output directory */ 57 | out: string; 58 | 59 | /** Static directory */ 60 | static: string; 61 | 62 | scripts: Record; 63 | 64 | styles: Record; 65 | }) { 66 | // clean build directory 67 | if (config.clean) { 68 | console.log("Cleaning build directory..."); 69 | await fsp.rm(config.out, {force: true, recursive: true}); 70 | } 71 | 72 | // ensure build directory exists 73 | await fsp.mkdir(config.out, {recursive: true}); 74 | 75 | // copy static files 76 | console.log("Copying files..."); 77 | await buildStatic(config); 78 | 79 | // webpack 80 | console.log("Creating production bundle..."); 81 | await buildBundle(config); 82 | } 83 | 84 | /** 85 | * Copy over static files. 86 | */ 87 | async function buildStatic(config: { 88 | out: string; 89 | static: string; 90 | scripts: Record; 91 | styles: Record; 92 | }) { 93 | const staticDir = path.resolve(process.cwd(), config.static); 94 | const scripts = Object.assign({}, defaultScripts, config.scripts); 95 | const styles = Object.assign({}, defaultStyles, config.styles); 96 | 97 | await walkDir(staticDir, async (filename) => { 98 | const relative = path.relative(staticDir, filename); 99 | const dest = path.join(config.out, relative); 100 | 101 | // apply html magic 102 | if (filename.endsWith(".html")) { 103 | const file = await fsp.readFile(filename, "utf8"); 104 | await idemWrite(dest, transform(file, {mode: "production", scripts, styles})) 105 | } else if (relative === "bundle.js") { 106 | 107 | } else { 108 | await fsp.mkdir(path.dirname(dest), {recursive: true}); 109 | await fsp.copyFile(filename, dest); 110 | } 111 | }); 112 | } 113 | 114 | /** 115 | * Compile bundle in production mode. 116 | */ 117 | async function buildBundle(config: { 118 | out: string; 119 | }) { 120 | // configure webpack 121 | process.env.NODE_ENV = "production"; 122 | const webpackConfig = require(path.join(process.cwd(), "webpack.config.js")); 123 | webpackConfig.mode = "production"; 124 | webpackConfig.output.path = config.out; 125 | 126 | const compiler = webpack(webpackConfig); 127 | 128 | // watch 129 | return new Promise(resolve => { 130 | compiler.run((err, stats) => { 131 | if (err) 132 | console.error(err); 133 | else { 134 | console.info(stats.toString({color: true})); 135 | } 136 | compiler.close((err, stats) => { 137 | resolve(); 138 | }); 139 | }); 140 | }); 141 | } 142 | 143 | 144 | /** 145 | * Write a file idempotently. 146 | */ 147 | async function idemWrite(filename: string, data: string) { 148 | try { 149 | const old = await fsp.readFile(filename, "utf8"); 150 | if (old !== data) 151 | await fsp.writeFile(filename, data); 152 | } catch (e) { 153 | await fsp.mkdir(path.dirname(filename), {recursive: true}); 154 | await fsp.writeFile(filename, data); 155 | } 156 | } 157 | 158 | /** 159 | * Recursively walk a directory. 160 | */ 161 | async function walkDir(dirname: string, callback: (filename: string) => Promise) { 162 | const files = (await fsp.readdir(dirname)).map(_ => path.join(dirname, _)); 163 | await Promise.all(files.map(async file => { 164 | const stats = await fsp.stat(file); 165 | if (stats.isDirectory()) { 166 | return walkDir(file, callback); 167 | } else { 168 | return callback(file); 169 | } 170 | })); 171 | } 172 | 173 | /** 174 | * Fix files. 175 | */ 176 | function coerceScripts(json: Record) { 181 | for (const key in json) { 182 | const record = json[key]; 183 | if (typeof record === "object") { 184 | if (typeof record.crossorigin === "string" && ["true","false"].includes(record.crossorigin)) { 185 | record.crossorigin = record.crossorigin === "true"; 186 | } 187 | } 188 | } 189 | return json; 190 | } -------------------------------------------------------------------------------- /packages/cli/src/tasks/config.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import path from "path"; 3 | import yargs from "yargs"; 4 | 5 | export const DEFAULT_LIST = ["liqvid.config.ts", "liqvid.config.js", "liqvid.config.json"]; 6 | export const DEFAULT_CONFIG = DEFAULT_LIST[0]; 7 | 8 | export function parseConfig(...keys: string[]) { 9 | require("ts-node/register/transpile-only"); 10 | 11 | return (configPath: string) => { 12 | try { 13 | return access(require(configPath), keys); 14 | } catch (e) { 15 | if (e.code === "MODULE_NOT_FOUND") { 16 | // default value => assume not specified 17 | if (path.join(process.cwd(), DEFAULT_CONFIG) === configPath) { 18 | return {}; 19 | } 20 | throw e; 21 | } else { 22 | throw e; 23 | } 24 | } 25 | }; 26 | } 27 | 28 | function access(o: any, keys: string[]): any { 29 | if (keys.length === 0) 30 | return o; 31 | const key = keys.shift(); 32 | if (!o[key]) 33 | return {}; 34 | return access(o[key], keys); 35 | } 36 | 37 | export const BROWSER_EXECUTABLE: yargs.Options = { 38 | alias: "x", 39 | desc: "Path to a Chrome/ium executable. If not specified and a suitable executable cannot be found, one will be downloaded during rendering.", 40 | normalize: true 41 | }; 42 | 43 | export const CONCURRENCY: yargs.Options = { 44 | alias: "n", 45 | default: Math.floor(os.cpus().length / 2), 46 | desc: "How many threads to use", 47 | type: "number" 48 | }; 49 | -------------------------------------------------------------------------------- /packages/cli/src/tasks/render.ts: -------------------------------------------------------------------------------- 1 | import {solidify} from "@liqvid/renderer"; 2 | import {parseTime} from "@liqvid/utils/time"; 3 | import type Yargs from "yargs"; 4 | import {BROWSER_EXECUTABLE, CONCURRENCY, DEFAULT_CONFIG, parseConfig} from "./config.js"; 5 | 6 | export const render = (yargs: typeof Yargs) => 7 | yargs.command("render", "Render static video", (yargs) => { 8 | yargs 9 | .config("config", parseConfig("render")) 10 | .default("config", DEFAULT_CONFIG) 11 | .example([ 12 | ["liqvid render"], 13 | ["liqvid render -a ./audio/audio.webm -o video.webm"], 14 | ["liqvid render -u http://localhost:8080/dist/"] 15 | ]) 16 | // Selection 17 | .group(["audio-file", "output", "url"], "What to render") 18 | .option("audio-file", { 19 | alias: "a", 20 | desc: "Path to audio file", 21 | normalize: true 22 | }) 23 | .option("output", { 24 | alias: "o", 25 | default: "./video.mp4", 26 | desc: "Output filename", 27 | normalize: true, 28 | demandOption: true 29 | }) 30 | .option("url", { 31 | alias: "u", 32 | desc: "URL of video to generate thumbs for", 33 | default: "http://localhost:3000/dist/" 34 | }) 35 | // General configuration 36 | .group(["browser-executable", "concurrency", "config", "help"], "General options") 37 | .option("browser-executable", BROWSER_EXECUTABLE) 38 | .option("concurrency", CONCURRENCY) 39 | // Input options 40 | .group(["duration", "end", "sequence", "start"], "Input options") 41 | .option("start", { 42 | alias: "s", 43 | coerce: coerceTime, 44 | default: "00:00", 45 | desc: "Start time, specify as [hh:]mm:ss[.ms]", 46 | type: "string" 47 | }) 48 | .option("duration", { 49 | alias: "d", 50 | coerce: coerceTime, 51 | conflicts: "end", 52 | desc: "Duration, specify as [hh:]mm:ss[.ms]", 53 | type: "string" 54 | }) 55 | .option("end", { 56 | alias: "e", 57 | coerce: coerceTime, 58 | desc: "End time, specify as [hh:]mm:ss[.ms]", 59 | type: "string" 60 | }) 61 | .option("sequence", { 62 | alias: "S", 63 | desc: "Output image sequence instead of video. If this flag is set, --output will be interpreted as a directory.", 64 | type: "boolean" 65 | }) 66 | // Frames 67 | .group(["height", "image-format", "quality", "width"], "Frame formatting") 68 | .option("height", { 69 | alias: "h", 70 | default: 800, 71 | desc: "Video height" 72 | }) 73 | .option("image-format", { 74 | alias: "F", 75 | choices: ["jpeg", "png"], 76 | default: "jpeg", 77 | desc: "Image format for frames" 78 | }) 79 | .option("quality", { 80 | alias: "q", 81 | default: 80, 82 | desc: "Quality for images. Only applies when --image-format is \"jpeg\"" 83 | }) 84 | .option("width", { 85 | alias: "w", 86 | default: 1280, 87 | desc: "Video width" 88 | }) 89 | // ffmpeg 90 | .group(["audio-args", "fps", "pixel-format", "video-args"], "Video options") 91 | .option("audio-args", { 92 | alias: "A", 93 | desc: "Additional flags to pass to ffmpeg, applying to the audio file" 94 | }) 95 | .option("fps", { 96 | alias: "r", 97 | default: 30, 98 | desc: "Frames per second" 99 | }) 100 | .option("pixel-format", { 101 | alias: "P", 102 | default: "yuv420p", 103 | desc: "Pixel format for ffmpeg" 104 | }) 105 | .option("video-args", { 106 | alias: "V", 107 | desc: "Additional flags to pass to ffmpeg, applying to the output video" 108 | }) 109 | .version(false); 110 | }, async (argv: Parameters[0]) => { 111 | await solidify(argv); 112 | process.exit(0); 113 | }) 114 | 115 | function coerceTime(v: string) { 116 | if (v === undefined) { 117 | return v; 118 | } 119 | try { 120 | return parseTime(v); 121 | } catch (e) { 122 | console.error(`Invalid time: ${v}. Specify as [hh:]mm:ss[.ms]`); 123 | process.exit(1); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /packages/cli/src/tasks/serve.ts: -------------------------------------------------------------------------------- 1 | import {createServer} from "@liqvid/server"; 2 | import path from "path"; 3 | import type Yargs from "yargs"; 4 | import {DEFAULT_CONFIG, parseConfig} from "./config.js"; 5 | 6 | /** 7 | * Run preview server 8 | */ 9 | export const serve = (yargs: typeof Yargs) => 10 | yargs 11 | .command("serve", "Run preview server", (yargs) => { 12 | return (yargs 13 | .config("config", parseConfig("serve")) 14 | .default("config", DEFAULT_CONFIG) 15 | .option("build", { 16 | alias: "b", 17 | coerce: path.resolve, 18 | desc: "Build directory", 19 | default: "./dist" 20 | }) 21 | .option("livereload-port", { 22 | alias: "L", 23 | desc: "Port to run LiveReload on", 24 | default: 0 25 | }) 26 | .option("port", { 27 | alias: "p", 28 | desc: "Port to run on", 29 | default: 3000 30 | }) 31 | .option("static", { 32 | alias: "s", 33 | coerce: path.resolve, 34 | desc: "Static directory", 35 | default: "./static" 36 | }) 37 | .option("scripts", { 38 | desc: "Script aliases", 39 | default: {} 40 | }) 41 | .option("styles", { 42 | desc: "Style aliases", 43 | default: {} 44 | }) 45 | ); 46 | }, (args) => { 47 | return new Promise((resolve, reject) => { 48 | const app = createServer(args); 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /packages/cli/src/tasks/thumbs.ts: -------------------------------------------------------------------------------- 1 | import {thumbs as renderThumbs} from "@liqvid/renderer"; 2 | import type Yargs from "yargs"; 3 | import {BROWSER_EXECUTABLE, CONCURRENCY, DEFAULT_CONFIG, parseConfig} from "./config.js"; 4 | 5 | export const thumbs = (yargs: typeof Yargs) => 6 | yargs.command("thumbs", "Generate thumbnails", (yargs) => { 7 | yargs 8 | .config("config", parseConfig("thumbs")) 9 | .default("config", DEFAULT_CONFIG) 10 | .example([ 11 | ["liqvid thumbs"], 12 | ["liqvid thumbs -u http://localhost:8080/dist/ -o ./dist/thumbs/%s.jpeg"] 13 | ]) 14 | // Selection 15 | .group(["output", "url"], "What to render") 16 | .option("output", { 17 | alias: "o", 18 | default: "./thumbs/%s.jpeg", 19 | desc: "Pattern for output filenames.", 20 | normalize: true 21 | }) 22 | .option("url", { 23 | alias: "u", 24 | desc: "URL of video to generate thumbs for", 25 | default: "http://localhost:3000/dist/" 26 | }) 27 | // General 28 | .group(["browser-executable", "concurrency", "config", "help"], "General options") 29 | .option("browser-executable", BROWSER_EXECUTABLE) 30 | .option("concurrency", CONCURRENCY) 31 | // Format 32 | .group(["color-scheme", "browser-height", "browser-width", "cols", "frequency", "height", "image-format", "quality", "rows", "width"], "Formatting") 33 | .option("color-scheme", { 34 | default: "light", 35 | choices: ["light", "dark"], 36 | desc: "Color scheme" 37 | }) 38 | .option("cols", { 39 | alias: "c", 40 | default: 5, 41 | desc: "The number of columns per sheet" 42 | }) 43 | .option("frequency", { 44 | alias: "f", 45 | default: 4, 46 | desc: "How many seconds between screenshots" 47 | }) 48 | .option("rows", { 49 | alias: "r", 50 | default: 5, 51 | desc: "The number of rows per sheet" 52 | }) 53 | .option("quality", { 54 | alias: "q", 55 | default: 80, 56 | desc: "Quality for images. Only applies when --image-format is \"jpeg\"" 57 | }) 58 | .option("height", { 59 | alias: "h", 60 | default: 100, 61 | desc: "Height of each thumbnail" 62 | }) 63 | .option("width", { 64 | alias: "w", 65 | default: 160, 66 | desc: "Width of each thumbnail" 67 | }) 68 | .option("browser-height", { 69 | alias: "H", 70 | desc: "Height of screenshot before resizing" 71 | }) 72 | .option("browser-width", { 73 | alias: "W", 74 | desc: "Width of screenshot before resizing" 75 | }) 76 | .option("image-format", { 77 | alias: "F", 78 | choices: ["jpeg", "png"], 79 | default: "jpeg", 80 | desc: "Image format for thumbnails" 81 | }) 82 | .version(false); 83 | }, async (argv: Parameters[0]) => { 84 | await renderThumbs(argv); 85 | process.exit(0); 86 | }); 87 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "module": "commonjs", 8 | "target": "esnext" 9 | }, 10 | "include": ["./src"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/gsap/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/gsap 2 | 3 | This module provides [GSAP](https://greensock.com/gsap/) integration for Liqvid. 4 | 5 | ## Installation 6 | 7 | $ npm install @liqvid/gsap 8 | 9 | ## Usage 10 | 11 | See the [GSAP docs](https://greensock.com/docs/) and especially the [React section](https://greensock.com/react). 12 | 13 | ```tsx 14 | import {useTimeline} from "@liqvid/gsap"; 15 | import {useEffect} from "react"; 16 | 17 | export function Demo() { 18 | const tl = useTimeline(); 19 | 20 | useEffect(() => { 21 | tl.to(".box", {duration: 3, x: 800}); 22 | tl.to(".box", {duration: 3, rotation: 360, y: 500}); 23 | tl.to(".box", {duration: 3, x: 0}); 24 | }, []); 25 | 26 | return ( 27 |
28 |
29 |
30 |
31 |
32 | ); 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /packages/gsap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/gsap", 3 | "version": "1.0.1", 4 | "description": "GSAP bindings for Liqvid", 5 | "keywords": ["animation", "GSAP", "Liqvid"], 6 | "main": "./dist/index.js", 7 | "typings": "./dist/index.d.ts", 8 | "files": [ 9 | "dist/*" 10 | ], 11 | "scripts": { 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/liqvidjs/liqvid.git" 17 | }, 18 | "author": "Yuri Sulyma ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/liqvidjs/liqvid/issues" 22 | }, 23 | "homepage": "https://github.com/liqvidjs/liqvid#readme", 24 | "peerDependencies": { 25 | "gsap": "^3.9.0", 26 | "liqvid": "^2.0.10" 27 | }, 28 | "sideEffects": false 29 | } 30 | -------------------------------------------------------------------------------- /packages/gsap/src/index.ts: -------------------------------------------------------------------------------- 1 | import gsap from "gsap"; 2 | import {Playback, usePlayer} from "liqvid"; 3 | 4 | const sym = Symbol(); 5 | 6 | declare module "liqvid" { 7 | interface Playback { 8 | [sym]: gsap.core.Timeline; 9 | } 10 | } 11 | 12 | /** 13 | * Get a GSAP timeline synced with Liqvid playback. 14 | */ 15 | export function useTimeline() { 16 | const {playback} = usePlayer(); 17 | if (!playback[sym]) { 18 | playback[sym] = syncTimeline(playback); 19 | } 20 | return playback[sym] as gsap.core.Timeline; 21 | } 22 | 23 | /** 24 | * Create a GSAP timeline and sync it with Liqvid playback. 25 | */ 26 | function syncTimeline(playback: Playback) { 27 | const tl = gsap.timeline({paused: true}); 28 | 29 | playback.hub.on("play", () => tl.resume()); 30 | playback.hub.on("pause", () => tl.pause()) 31 | playback.hub.on("ratechange", () => tl.timeScale(playback.playbackRate)); 32 | playback.hub.on("seek", () => tl.seek(playback.currentTime / 1000)); 33 | 34 | return tl; 35 | } 36 | -------------------------------------------------------------------------------- /packages/gsap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "module": "commonjs" 8 | }, 9 | "include": ["./src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/gsap/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@liqvid/playback@^0.0.1": 6 | version "0.0.1" 7 | resolved "https://registry.yarnpkg.com/@liqvid/playback/-/playback-0.0.1.tgz#3cc1a1258330b962a38a2f4297947c153b33bb8a" 8 | integrity sha512-XqmY90VeRmAnsZfPpNWF7jSFUnWeRG9kUy55GLShuoL+87l93n1IClRK2JhPFbZqaf4+PVflRgcsFw1Qrz6rGg== 9 | dependencies: 10 | "@liqvid/utils" "*" 11 | "@types/events" "^3.0.0" 12 | events "^3.3.0" 13 | strict-event-emitter-types "^2.0.0" 14 | 15 | "@liqvid/utils@*": 16 | version "0.0.1" 17 | resolved "https://registry.yarnpkg.com/@liqvid/utils/-/utils-0.0.1.tgz#696cc14f3e630e6dfea0171cda24855667cdd9f8" 18 | integrity sha512-HsuUQ2cKV3rPt6cOqWehS+NjN1Yqsz4zvPe+UqcrWywKzE+oM0br1ocjnJvpmPgo5A317okhLlByv/J5m9MPIQ== 19 | dependencies: 20 | bezier-easing "^2.1.0" 21 | 22 | "@types/events@^3.0.0": 23 | version "3.0.0" 24 | resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" 25 | integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== 26 | 27 | bezier-easing@^2.1.0: 28 | version "2.1.0" 29 | resolved "https://registry.yarnpkg.com/bezier-easing/-/bezier-easing-2.1.0.tgz#c04dfe8b926d6ecaca1813d69ff179b7c2025d86" 30 | integrity sha1-wE3+i5JtbsrKGBPWn/F5t8ICXYY= 31 | 32 | events@^3.3.0: 33 | version "3.3.0" 34 | resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" 35 | integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== 36 | 37 | gsap@^3.9.0: 38 | version "3.9.0" 39 | resolved "https://registry.yarnpkg.com/gsap/-/gsap-3.9.0.tgz#c5cf85b686ccfe530bb9f309f1b42efde6cf9841" 40 | integrity sha512-YfIBNHJu4UHES1Vj780+sXtQuiD78QQwgJqktaXE9PO9OuXz5l4ETz05pnhxUfJcxJy4SUINXJxT9ZZhuYwU2g== 41 | 42 | liqvid@^2.0.10: 43 | version "2.0.10" 44 | resolved "https://registry.yarnpkg.com/liqvid/-/liqvid-2.0.10.tgz#680dbc0040b2a3fde6f7bdedc785df40ba8fdbcd" 45 | integrity sha512-B5pj/P6RYQdVFAqUplp6c0lW8IdqEeITylmSIj2h5wB9/ff5njhcSgoZ3RjpiNAqbxnOKdna4DqGQVPDtLG9eQ== 46 | 47 | strict-event-emitter-types@^2.0.0: 48 | version "2.0.0" 49 | resolved "https://registry.yarnpkg.com/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz#05e15549cb4da1694478a53543e4e2f4abcf277f" 50 | integrity sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA== 51 | -------------------------------------------------------------------------------- /packages/host/README.md: -------------------------------------------------------------------------------- 1 | # lv-host 2 | 3 | This package provides a script which should be included in pages hosting [Liqvid](https://liqvidjs.org) videos. Currently, all it does is shim fullscreen behavior in iOS. 4 | -------------------------------------------------------------------------------- /packages/host/lv-host.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (() => { 4 | 5 | const setDims = () => { 6 | document.body.style.setProperty("--vh", `${window.innerHeight}px`); 7 | document.body.style.setProperty("--vw", `${window.innerWidth}px`); 8 | document.body.style.setProperty("--scroll-y", `${window.scrollY || 0}px`); 9 | }; 10 | 11 | document.addEventListener("DOMContentLoaded", () => { 12 | // add CSS 13 | { 14 | const style = document.createElement("style"); 15 | style.setAttribute("type", "text/css"); 16 | style.textContent = ` 17 | iframe.fake-fullscreen { 18 | position: fixed; 19 | top: 0;/*var(--scroll-y);*/ 20 | left: 0; 21 | height: var(--vh); 22 | width: var(--vw); 23 | z-index: 10000; 24 | } 25 | 26 | @media (orientation: portrait) { 27 | iframe.fake-fullscreen { 28 | transform: rotate(-90deg); 29 | transform-origin: top left; 30 | left: 0; 31 | top: 100%; 32 | width: var(--vh); 33 | height: var(--vw); 34 | } 35 | }`; 36 | document.head.appendChild(style); 37 | } 38 | 39 | // resize listener 40 | window.addEventListener("resize", setDims); 41 | setDims(); 42 | 43 | // live collection of iframes 44 | const iframes = document.getElementsByTagName("iframe"); 45 | 46 | const listener = (e) => { 47 | for (let i = 0; i < iframes.length; ++i) { 48 | const iframe = iframes.item(i); 49 | if (iframe.allowFullscreen && !document.fullscreenEnabled && iframe.contentWindow === e.source) { 50 | // handle the resize event 51 | if ("type" in e.data && e.data.type === "fake-fullscreen") { 52 | // resize event doesn't work reliably in iOS... 53 | setDims(); 54 | iframe.classList.toggle("fake-fullscreen", e.data.value); 55 | } 56 | return; 57 | } 58 | } 59 | }; 60 | 61 | // communicate with children 62 | window.addEventListener("message", listener); 63 | }); 64 | 65 | })(); 66 | -------------------------------------------------------------------------------- /packages/host/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/host", 3 | "version": "1.1.0", 4 | "description": "Liqvid host page script", 5 | "files": [ 6 | "lv-host.js" 7 | ], 8 | "main": "lv-host.js", 9 | "keywords": ["Liqvid", "React", "Javascript"], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/liqvidjs/liqvid.git" 13 | }, 14 | "author": "Yuri Sulyma ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/liqvidjs/liqvid/issues" 18 | }, 19 | "homepage": "https://github.com/liqvidjs/liqvid#readme" 20 | } 21 | -------------------------------------------------------------------------------- /packages/katex/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@yuri" 3 | } 4 | -------------------------------------------------------------------------------- /packages/katex/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/katex 2 | 3 | [KaTeX](https://katex.org/) integration for [Liqvid](https://liqvidjs.org). 4 | 5 | ## Usage 6 | 7 | ```tsx 8 | import {KTX} from "@liqvid/katex"; 9 | 10 | function Quadratic() { 11 | return ( 12 |
13 | The value of x is given by the quadratic formula 14 | {String.raw`x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}`} 15 |
16 | ); 17 | } 18 | ``` 19 | 20 | ## Macros 21 | 22 | For convenience, this module supports loading macro definitions from a file. Simply include a ` 27 | ``` 28 | ```tex 29 | % macros.tex 30 | \newcommand{\C}{\mathbb C} 31 | ``` 32 | -------------------------------------------------------------------------------- /packages/katex/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/katex", 3 | "version": "0.0.3", 4 | "description": "KaTeX integration for Liqvid", 5 | "files": [ 6 | "dist/*" 7 | ], 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.mjs", 11 | "require": "./dist/index.js" 12 | }, 13 | "./plain": { 14 | "import": "./dist/plain.mjs", 15 | "require": "./dist/plain.js" 16 | } 17 | }, 18 | "typesVersions": { 19 | "*": { 20 | "*": [ 21 | "./dist/types/*" 22 | ] 23 | } 24 | }, 25 | "author": "Yuri Sulyma ", 26 | "keywords": [ 27 | "liqvid", 28 | "katex" 29 | ], 30 | "scripts": { 31 | "build": "tsc --build --force", 32 | "lint": "eslint --ext ts,tsx --fix src" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/liqvidjs/liqvid.git" 37 | }, 38 | "bugs": { 39 | "url": "https://github.com/liqvidjs/liqvid/issues" 40 | }, 41 | "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/katex", 42 | "license": "MIT", 43 | "peerDependencies": { 44 | "@types/katex": "^0.11.1", 45 | "@types/react": ">=17.0.0", 46 | "liqvid": "^2.1.1", 47 | "react": ">=17.0.0" 48 | }, 49 | "peerDependenciesMeta": { 50 | "liqvid": { 51 | "optional": true 52 | } 53 | }, 54 | "devDependencies": { 55 | "@types/react": "^17.0.40", 56 | "@typescript-eslint/eslint-plugin": "^5.14.0", 57 | "@typescript-eslint/parser": "^5.14.0", 58 | "@yuri/eslint-config": "^1.0.1", 59 | "eslint": "^8.11.0", 60 | "eslint-plugin-react": "^7.29.3", 61 | "liqvid": "^2.1.1", 62 | "react": "^17.0.2", 63 | "rollup": "^2.70.0", 64 | "typescript": "^4.6.2" 65 | }, 66 | "dependencies": { 67 | "@liqvid/utils": "^1.3.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/katex/src/RenderGroup.ts: -------------------------------------------------------------------------------- 1 | import {recursiveMap, usePromise} from "@liqvid/utils/react"; 2 | import {usePlayer} from "liqvid"; 3 | import React, {cloneElement, forwardRef, isValidElement, ReactElement, useEffect, useImperativeHandle, useRef} from "react"; 4 | import {KTX} from "./fancy"; 5 | import {Handle as KTXHandle, KTX as KTXPlain} from "./plain"; 6 | 7 | /** RenderGroup element API */ 8 | interface Handle { 9 | /** Promise that resolves once all KTX descendants have finished typesetting */ 10 | ready: Promise; 11 | } 12 | 13 | interface Props { 14 | /** 15 | * Whether to reparse descendants for `during()` and `from()` 16 | * @default false 17 | */ 18 | reparse?: boolean; 19 | } 20 | 21 | /** 22 | * Wait for several things to be rendered 23 | */ 24 | export const RenderGroup = forwardRef(function RenderGroup(props, ref) { 25 | const [ready, resolve] = usePromise(); 26 | 27 | // handle 28 | useImperativeHandle(ref, () => ({ready})); 29 | 30 | const elements = useRef([]); 31 | const promises = useRef[]>([]); 32 | 33 | // reparsing 34 | const player = usePlayer(); 35 | useEffect(() => { 36 | // promises 37 | Promise.all(promises.current).then(() => { 38 | 39 | // reparse 40 | if (props.reparse) { 41 | player.reparseTree(leastCommonAncestor(elements.current)); 42 | } 43 | 44 | // ready() 45 | resolve(); 46 | }); 47 | }, []); 48 | 49 | return recursiveMap(props.children, node => { 50 | if (shouldInspect(node)) { 51 | const originalRef = node.ref; 52 | return cloneElement(node, { 53 | ref: (ref: KTXHandle) => { 54 | if (!ref) return; 55 | 56 | elements.current.push(ref.domElement); 57 | promises.current.push(ref.ready); 58 | 59 | // pass along original ref 60 | if (typeof originalRef === "function") { 61 | originalRef(ref); 62 | } else if (originalRef && typeof originalRef === "object") { 63 | (originalRef as React.MutableRefObject).current = ref; 64 | } 65 | } 66 | }); 67 | } 68 | 69 | return node; 70 | }) as unknown as React.ReactElement; 71 | }); 72 | 73 | /** 74 | * Determine whether the node is a element 75 | * @param node Element to check 76 | */ 77 | function shouldInspect(node: React.ReactNode): node is React.ReactElement & React.RefAttributes { 78 | return isValidElement(node) && typeof node.type === "object" && (node.type === KTX || node.type === KTXPlain); 79 | } 80 | 81 | /** 82 | * Find least common ancestor of an array of elements 83 | * @param elements Elements 84 | * @returns Deepest node containing all passed elements 85 | */ 86 | function leastCommonAncestor(elements: HTMLElement[]): HTMLElement { 87 | if (elements.length === 0) { 88 | throw new Error("Must pass at least one element"); 89 | } 90 | 91 | let ancestor = elements[0]; 92 | let failing = elements.slice(1); 93 | while (failing.length > 0) { 94 | ancestor = ancestor.parentElement; 95 | failing = failing.filter(node => !ancestor.contains(node)); 96 | } 97 | return ancestor; 98 | } 99 | -------------------------------------------------------------------------------- /packages/katex/src/fancy.tsx: -------------------------------------------------------------------------------- 1 | import {combineRefs} from "@liqvid/utils/react"; 2 | import {usePlayer} from "liqvid"; 3 | import {forwardRef, useEffect, useRef} from "react"; 4 | import {Handle, KTX as KTXPlain} from "./plain"; 5 | 6 | interface Props extends React.ComponentProps { 7 | /** 8 | * Player events to obstruct 9 | * @default "canplay canplaythrough" 10 | */ 11 | obstruct?: string; 12 | 13 | /** 14 | * Whether to reparse descendants for `during()` and `from()` 15 | * @default false 16 | */ 17 | reparse?: boolean; 18 | } 19 | 20 | /** Component for KaTeX code */ 21 | export const KTX = forwardRef(function KTX(props, ref) { 22 | const {obstruct = "canplay canplaythrough", reparse = false, ...attrs} = props; 23 | 24 | const plain = useRef(); 25 | const combined = combineRefs(plain, ref); 26 | 27 | const player = usePlayer(); 28 | 29 | useEffect(() => { 30 | // obstruction 31 | if (obstruct.match(/\bcanplay\b/)) { 32 | player.obstruct("canplay", plain.current.ready); 33 | } 34 | if (obstruct.match("canplaythrough")) { 35 | player.obstruct("canplaythrough", plain.current.ready); 36 | } 37 | 38 | // reparsing 39 | if (reparse) { 40 | plain.current.ready.then(() => player.reparseTree(plain.current.domElement)); 41 | } 42 | }, []); 43 | 44 | return (); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/katex/src/index.tsx: -------------------------------------------------------------------------------- 1 | export {KTX} from "./fancy"; 2 | export {KaTeXReady} from "./loading"; 3 | export {Handle} from "./plain"; 4 | export {RenderGroup} from "./RenderGroup"; 5 | 6 | declare global { 7 | const katex: typeof katex; 8 | } 9 | -------------------------------------------------------------------------------- /packages/katex/src/loading.ts: -------------------------------------------------------------------------------- 1 | // option of loading KaTeX asynchronously 2 | const KaTeXLoad = new Promise((resolve) => { 3 | const script = document.querySelector("script[src*=\"katex.js\"], script[src*=\"katex.min.js\"]"); 4 | if (!script) return; 5 | 6 | if (window.hasOwnProperty("katex")) { 7 | resolve(katex); 8 | } else { 9 | script.addEventListener("load", () => resolve(katex)); 10 | } 11 | }); 12 | 13 | // load macros from 14 | const KaTeXMacros = new Promise<{[key: string]: string;}>((resolve) => { 15 | const macros: {[key: string]: string;} = {}; 16 | const scripts: HTMLScriptElement[] = Array.from(document.querySelectorAll("head > script[type='math/tex']")); 17 | return Promise.all( 18 | scripts.map(script => 19 | fetch(script.src) 20 | .then(res => { 21 | if (res.ok) 22 | return res.text(); 23 | throw new Error(`${res.status} ${res.statusText}: ${script.src}`); 24 | }) 25 | .then(tex => { 26 | Object.assign(macros, parseMacros(tex)); 27 | }) 28 | ) 29 | ).then(() => resolve(macros)); 30 | }); 31 | 32 | /** 33 | * Ready Promise 34 | */ 35 | export const KaTeXReady = Promise.all([KaTeXLoad, KaTeXMacros]); 36 | 37 | /** 38 | * Parse \newcommand macros in a file. 39 | * Also supports \ktxnewcommand (for use in conjunction with MathJax). 40 | * @param file TeX file to parse 41 | */ 42 | function parseMacros(file: string) { 43 | const macros: Record = {}; 44 | const rgx = /\\(?:ktx)?newcommand\{(.+?)\}(?:\[\d+\])?\{/g; 45 | let match: RegExpExecArray; 46 | 47 | while (match = rgx.exec(file)) { 48 | let body = ""; 49 | 50 | const macro = match[1]; 51 | let braceCount = 1; 52 | 53 | for (let i = match.index + match[0].length; (braceCount > 0) && (i < file.length); ++i) { 54 | const char = file[i]; 55 | if (char === "{") { 56 | braceCount++; 57 | } else if (char === "}") { 58 | braceCount--; 59 | if (braceCount === 0) 60 | break; 61 | } else if (char === "\\") { 62 | body += file.slice(i, i+2); 63 | ++i; 64 | continue; 65 | } 66 | body += char; 67 | } 68 | macros[macro] = body; 69 | } 70 | return macros; 71 | } 72 | -------------------------------------------------------------------------------- /packages/katex/src/plain.tsx: -------------------------------------------------------------------------------- 1 | import {forwardRef, useEffect, useImperativeHandle, useRef} from "react"; 2 | import {KaTeXReady} from "./loading"; 3 | import {usePromise} from "@liqvid/utils/react"; 4 | 5 | /** 6 | * KTX element API 7 | */ 8 | export interface Handle { 9 | /** The underlying element */ 10 | domElement: HTMLSpanElement; 11 | 12 | /** Promise that resolves once typesetting is finished */ 13 | ready: Promise; 14 | } 15 | 16 | interface Props extends React.HTMLAttributes { 17 | /** 18 | * Whether to render in display style 19 | * @default false 20 | */ 21 | display?: boolean; 22 | } 23 | 24 | /** Component for KaTeX code */ 25 | export const KTX = forwardRef(function KTX(props, ref) { 26 | const spanRef = useRef(); 27 | const {children, display = false, ...attrs} = props; 28 | const [ready, resolve] = usePromise(); 29 | 30 | // handle 31 | useImperativeHandle(ref, () => ({ 32 | domElement: spanRef.current, 33 | ready 34 | })); 35 | 36 | useEffect(() => { 37 | KaTeXReady.then(([katex, macros]) => { 38 | katex.render(children.toString(), spanRef.current, { 39 | displayMode: !!display, 40 | macros, 41 | strict: "ignore", 42 | throwOnError: false, 43 | trust: true 44 | }); 45 | 46 | /* move katex into placeholder element */ 47 | const child = spanRef.current.firstElementChild as HTMLSpanElement; 48 | 49 | // copy classes 50 | for (let i = 0, len = child.classList.length; i < len; ++i) { 51 | spanRef.current.classList.add(child.classList.item(i)); 52 | } 53 | 54 | // move children 55 | while (child.childNodes.length > 0) { 56 | spanRef.current.appendChild(child.firstChild); 57 | } 58 | 59 | // delete child 60 | child.remove(); 61 | 62 | // resolve promise 63 | resolve(); 64 | }); 65 | }, [children]); 66 | 67 | // Google Chrome fails without this 68 | if (display) { 69 | if (!attrs.style) 70 | attrs.style = {}; 71 | attrs.style.display = "block"; 72 | } 73 | 74 | return ( 75 | 76 | ); 77 | }); 78 | -------------------------------------------------------------------------------- /packages/katex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "declarationDir": "./dist/types", 6 | "outDir": "./dist", 7 | "rootDir": "./src" 8 | }, 9 | "include": ["./src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/keymap/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": ["plugin:@typescript-eslint/recommended"], 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": 2018, 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "@typescript-eslint" 21 | ], 22 | "settings": { 23 | "react": { 24 | "version": "detect" 25 | } 26 | }, 27 | "rules": { 28 | "@typescript-eslint/no-unused-vars": ["error", { 29 | "ignoreRestSiblings": true 30 | }], 31 | 32 | "@typescript-eslint/explicit-function-return-type": ["off"], 33 | 34 | "@typescript-eslint/explicit-member-accessibility": ["error", { 35 | "accessibility": "no-public" 36 | }], 37 | 38 | "@typescript-eslint/indent": ["error", 2, { 39 | "MemberExpression": 0, 40 | "VariableDeclarator": { "var": 2, "let": 2, "const": 3 } 41 | }], 42 | 43 | "@typescript-eslint/no-use-before-define": ["off"], 44 | 45 | "@typescript-eslint/type-annotation-spacing": ["error", { 46 | "before": false, 47 | "overrides": { 48 | "arrow": {"before": true} 49 | } 50 | }], 51 | 52 | "linebreak-style": ["error", "unix"], 53 | "quotes": ["error", "double"], 54 | "semi": ["error", "always"] 55 | } 56 | } -------------------------------------------------------------------------------- /packages/keymap/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/keymap 2 | 3 | This package provides key bindings for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/reference/KeyMap for documentation. 4 | -------------------------------------------------------------------------------- /packages/keymap/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | testPathIgnorePatterns: ['dist'], 5 | coverageReporters: ['json-summary'], 6 | transform: {} 7 | }; 8 | -------------------------------------------------------------------------------- /packages/keymap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/keymap", 3 | "version": "1.1.2", 4 | "description": "Key binding for Liqvid", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/liqvidjs/liqvid.git" 8 | }, 9 | "exports": { 10 | ".": { 11 | "import": "./dist/esm/index.mjs", 12 | "require": "./dist/cjs/index.cjs" 13 | }, 14 | "./react": { 15 | "import": "./dist/esm/react.mjs", 16 | "require": "./dist/cjs/react.cjs" 17 | } 18 | }, 19 | "typesVersions": { 20 | "*": { 21 | "*": [ 22 | "./dist/types/*" 23 | ] 24 | } 25 | }, 26 | "files": [ 27 | "dist/*" 28 | ], 29 | "scripts": { 30 | "build": "npm=$npm_execpath; $npm build:clean && $npm build:cjs && $npm build:esm && $npm build:postclean", 31 | "build:clean": "rm -rf dist", 32 | "build:cjs": "npm=$npm_execpath; $npm build:cjs:tsc && $npm build:cjs:rename", 33 | "build:cjs:tsc": "tsc --module commonjs --outDir dist/cjs", 34 | "build:cjs:rename": "for i in ./dist/cjs/*.js; do mv -- \"$i\" \"${i%.js}.cjs\"; done", 35 | "build:esm": "npm=$npm_execpath; $npm build:esm:tsc && $npm build:esm:fix", 36 | "build:esm:tsc": "tsc --module esnext --outDir dist/esm", 37 | "build:esm:fix": "node ../../build.mjs", 38 | "build:postclean": "find ./dist -name tsconfig.tsbuildinfo -delete", 39 | "lint": "eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests", 40 | "test": "eslint src --ext ts,tsx && jest --coverage" 41 | }, 42 | "author": "Yuri Sulyma ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/liqvidjs/liqvid/issues" 46 | }, 47 | "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/keymap#readme", 48 | "devDependencies": { 49 | "@types/jest": "^27.4.1", 50 | "@types/react": "^17.0.40", 51 | "@typescript-eslint/eslint-plugin": "^5.14.0", 52 | "@typescript-eslint/parser": "^5.14.0", 53 | "eslint": "^8.11.0", 54 | "jest": "^27.5.1", 55 | "react": "^17.0.2", 56 | "ts-jest": "^27.1.3", 57 | "typescript": "^4.6.2" 58 | }, 59 | "sideEffects": false, 60 | "peerDependencies": { 61 | "@types/react": "^17.0.40", 62 | "react": "^17.0.2" 63 | }, 64 | "peerDependenciesMeta": { 65 | "react": { 66 | "optional": true 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /packages/keymap/src/index.ts: -------------------------------------------------------------------------------- 1 | import {mixedCaseVals} from "./mixedCaseVals"; 2 | 3 | type Callback = (e: KeyboardEvent) => void; 4 | 5 | interface Bindings { 6 | [key: string]: Callback[]; 7 | } 8 | 9 | const modifierMap = { 10 | Control: "Ctrl", 11 | Alt: "Alt", 12 | Shift: "Shift", 13 | Meta: "Meta" 14 | }; 15 | 16 | const mixedCase: {[key: string]: string} = {}; 17 | for (const key of mixedCaseVals) { 18 | mixedCase[key.toLowerCase()] = key; 19 | } 20 | 21 | const modifierOrder = (Object.keys(modifierMap) as (keyof typeof modifierMap)[]).map(k => modifierMap[k]); 22 | 23 | const useCode = [ 24 | "Backspace", 25 | "Enter", 26 | "Space", 27 | "Tab" 28 | ]; 29 | 30 | /** Maps keyboard shortcuts to actions */ 31 | export class Keymap { 32 | private __bindings: Bindings; 33 | 34 | constructor() { 35 | this.__bindings = {}; 36 | } 37 | 38 | /** Given a KeyboardEvent, returns a shortcut sequence matching that event. */ 39 | static identify(e: KeyboardEvent) { 40 | const parts: string[] = []; 41 | for (const modifier in modifierMap) { 42 | if (e.getModifierState(modifier)) { 43 | parts.push(modifierMap[modifier as keyof typeof modifierMap]); 44 | } 45 | } 46 | if (e.key in modifierMap) { 47 | } else if (e.code.startsWith("Digit")) { 48 | parts.push(e.code.slice(5)); 49 | } else if (e.code.startsWith("Key")) { 50 | parts.push(e.code.slice(3)); 51 | } else if (useCode.includes(e.code)) { 52 | parts.push(e.code); 53 | } else { 54 | parts.push(e.key); 55 | } 56 | return parts.join("+"); 57 | } 58 | 59 | /** Returns a canonical form of the shortcut sequence. */ 60 | static normalize(seq: string) { 61 | return seq.split("+").map(str => { 62 | const lower = str.toLowerCase(); 63 | 64 | if (str === "") 65 | return ""; 66 | 67 | if (mixedCase[lower]) { 68 | return mixedCase[lower]; 69 | } 70 | 71 | return str[0].toUpperCase() + lower.slice(1); 72 | }).sort((a, b) => { 73 | if (modifierOrder.includes(a)) { 74 | if (modifierOrder.includes(b)) { 75 | return modifierOrder.indexOf(a) - modifierOrder.indexOf(b); 76 | } else { 77 | return -1; 78 | } 79 | } else if (modifierOrder.includes(b)) { 80 | return 1; 81 | } else { 82 | return cmp(a, b); 83 | } 84 | }).join("+"); 85 | } 86 | 87 | /** 88 | * Bind a handler to be called when the shortcut sequence is pressed. 89 | * @param seq Shortcut sequence 90 | * @param cb Callback function 91 | */ 92 | bind(seq: string, cb: Callback) { 93 | if (seq.indexOf(",") > -1) { 94 | for (const atomic of seq.split(",")) { 95 | this.bind(atomic, cb); 96 | } 97 | return; 98 | } 99 | seq = Keymap.normalize(seq); 100 | if (!this.__bindings.hasOwnProperty(seq)) { 101 | this.__bindings[seq] = []; 102 | } 103 | this.__bindings[seq].push(cb); 104 | } 105 | 106 | /** 107 | * Unbind a handler from a shortcut sequence. 108 | * @param seq Shortcut sequence 109 | * @param cb Handler to unbind 110 | */ 111 | unbind(seq: string, cb: Callback) { 112 | if (seq.indexOf(",") > -1) { 113 | for (const atomic of seq.split(",")) { 114 | this.unbind(atomic, cb); 115 | } 116 | return; 117 | } 118 | seq = Keymap.normalize(seq); 119 | if (!this.__bindings.hasOwnProperty(seq)) 120 | throw new Error(`${seq} is not bound`); 121 | const index = this.__bindings[seq].indexOf(cb); 122 | if (index < 0) { 123 | throw new Error(`${seq} is not bound to ${cb.name ?? "callback"}`); 124 | } 125 | this.__bindings[seq].splice(index, 1); 126 | if (this.__bindings[seq].length === 0) { 127 | delete this.__bindings[seq]; 128 | } 129 | } 130 | 131 | /** Return all shortcut sequences with handlers bound to them. */ 132 | getKeys() { 133 | return Object.keys(this.__bindings); 134 | } 135 | 136 | /** Get the list of handlers for a given shortcut sequence. */ 137 | getHandlers(seq: string) { 138 | if (!this.__bindings.hasOwnProperty(seq)) 139 | return []; 140 | return this.__bindings[seq].slice(); 141 | } 142 | 143 | /** Dispatches all handlers matching the given event. */ 144 | handle(e: KeyboardEvent) { 145 | const seq = Keymap.identify(e); 146 | 147 | if (!this.__bindings[seq] && !this.__bindings["*"]) 148 | return; 149 | 150 | if (this.__bindings[seq]) { 151 | e.preventDefault(); 152 | 153 | for (const cb of this.__bindings[seq]) { 154 | cb(e); 155 | } 156 | } 157 | 158 | if (this.__bindings["*"]) { 159 | for (const cb of this.__bindings["*"]) { 160 | cb(e); 161 | } 162 | } 163 | } 164 | } 165 | 166 | /** 167 | * Returns -1 if a < b, 0 if a === b, and 1 if a > b. 168 | */ 169 | function cmp(a: T, b: T) { 170 | if (a < b) 171 | return -1; 172 | else if (a === b) 173 | return 0; 174 | return 1; 175 | } 176 | -------------------------------------------------------------------------------- /packages/keymap/src/mixedCaseVals.ts: -------------------------------------------------------------------------------- 1 | export const mixedCaseVals = [ 2 | "AltGraph", 3 | "CapsLock", 4 | "FnLock", 5 | "NumLock", 6 | "ScrollLock", 7 | "SymbolLock", 8 | "ArrowDown", 9 | "ArrowLeft", 10 | "ArrowRight", 11 | "ArrowUp", 12 | "PageDown", 13 | "PageUp", 14 | "CrSel", 15 | "EraseEof", 16 | "ExSel", 17 | "ContextMenu", 18 | "ZoomIn", 19 | "ZoomOut", 20 | "BrightnessDown", 21 | "BrightnessUp", 22 | "LogOff", 23 | "PowerOff", 24 | "PrintScreen", 25 | "WakeUp", 26 | "AllCandidates", 27 | "CodeInput", 28 | "FinalMode", 29 | "GroupFirst", 30 | "GroupLast", 31 | "GroupNext", 32 | "GroupPrevious", 33 | "ModeChange", 34 | "NextCandidate", 35 | "NonConvert", 36 | "PreviousCandidate", 37 | "SingleCandidate", 38 | "HangulMode", 39 | "HanjaMode", 40 | "JunjaMode", 41 | "HiraganaKatakana", 42 | "KanaMode", 43 | "KanjiMode", 44 | "ZenkakuHanaku", 45 | "AppSwitch", 46 | "CameraFocus", 47 | "EndCall", 48 | "GoBack", 49 | "GoHome", 50 | "HeadsetHook", 51 | "LastNumberRedial", 52 | "MannerMode", 53 | "VoiceDial", 54 | "ChannelDown", 55 | "ChannelUp", 56 | "MediaFastForward", 57 | "MediaPause", 58 | "MediaPlay", 59 | "MediaPlayPause", 60 | "MediaRecord", 61 | "MediaRewind", 62 | "MediaStop", 63 | "MediaTrackNext", 64 | "MediaTrackPrevious", 65 | "AudioBalanceLeft", 66 | "AudioBalanceRight", 67 | "AudioBassDown", 68 | "AudioBassBoostDown", 69 | "AudioBassBoostToggle", 70 | "AudioBassBoostUp", 71 | "AudioBassUp", 72 | "AudioFaderFront", 73 | "AudioFaderRear", 74 | "AudioSurroundModeNext", 75 | "AudioTrebleDown", 76 | "AudioTrebleUp", 77 | "AudioVolumeDown", 78 | "AudioVolumeMute", 79 | "AudioVolumeUp", 80 | "MicrophoneToggle", 81 | "MicrophoneVolumeDown", 82 | "MicrophoneVolumeMute", 83 | "MicrophoneVolumeUp", 84 | "TV", 85 | "TVAntennaCable", 86 | "TVAudioDescription", 87 | "TVAudioDescriptionMixDown", 88 | "TVAudioDescriptionMixUp", 89 | "TVContentsMenu", 90 | "TVDataService", 91 | "TVInput", 92 | "TVMediaContext", 93 | "TVNetwork", 94 | "TVNumberEntry", 95 | "TVPower", 96 | "TVRadioService", 97 | "TVSatellite", 98 | "TVSatelliteBS", 99 | "TVSatelliteCS", 100 | "TVSatelliteToggle", 101 | "TVTerrestrialAnalog", 102 | "TVTerrestrialDigital", 103 | "TVTimer", 104 | "AVRInput", 105 | "AVRPower", 106 | "ClosedCaptionToggle", 107 | "DisplaySwap", 108 | "DVR", 109 | "GuideNextDay", 110 | "GuidePreviousDay", 111 | "InstantReplay", 112 | "ListProgram", 113 | "LiveContent", 114 | "MediaApps", 115 | "MediaAudioTrack", 116 | "MediaLast", 117 | "MediaSkipBackward", 118 | "MediaSkipForward", 119 | "MediaStepBackward", 120 | "MediaStepForward", 121 | "MediaTopMenu", 122 | "NavigateIn", 123 | "NavigateNext", 124 | "NavigateOut", 125 | "NavigatePrevious", 126 | "NextFavoriteChannel", 127 | "NextUserProfile", 128 | "OnDemand", 129 | "PinPDown", 130 | "PinPMove", 131 | "PinPToggle", 132 | "PinPUp", 133 | "PlaySpeedDown", 134 | "PlaySpeedReset", 135 | "PlaySpeedUp", 136 | "RandomToggle", 137 | "RcLowBattery", 138 | "RecordSpeedNext", 139 | "RfBypass", 140 | "ScanChannelsToggle", 141 | "ScreenModeNext", 142 | "SplitScreenToggle", 143 | "STBInput", 144 | "STBPower", 145 | "VideoModeNext", 146 | "ZoomToggle", 147 | "SpeechCorrectionList", 148 | "SpeechInputToggle", 149 | "SpellCheck", 150 | "MailForward", 151 | "MailReply", 152 | "MailSend", 153 | "LaunchCalculator", 154 | "LaunchCalendar", 155 | "LaunchContacts", 156 | "LaunchMail", 157 | "LaunchMediaPlayer", 158 | "LaunchMusicPlayer", 159 | "LaunchMyComputer", 160 | "LaunchPhone", 161 | "LaunchScreenSaver", 162 | "LaunchSpreadsheet", 163 | "LaunchWebBrowser", 164 | "LaunchWebCam", 165 | "LaunchWordProcessor", 166 | "BrowserBack", 167 | "BrowserFavorites", 168 | "BrowserForward", 169 | "BrowserHome", 170 | "BrowserRefresh", 171 | "BrowserSearch", 172 | "BrowserStop" 173 | ]; 174 | -------------------------------------------------------------------------------- /packages/keymap/src/react.ts: -------------------------------------------------------------------------------- 1 | import {createContext, useContext} from "react"; 2 | import {Keymap} from "."; 3 | 4 | const symbol = Symbol.for("@lqv/keymap"); 5 | 6 | type GlobalThis = { 7 | [symbol]: React.Context; 8 | } 9 | 10 | if (!(symbol in globalThis)) { 11 | (globalThis as unknown as GlobalThis)[symbol] = createContext(null); 12 | } 13 | 14 | /** 15 | * {@link React.Context} used to access ambient Keymap 16 | */ 17 | export const KeymapContext = (globalThis as unknown as GlobalThis)[symbol]; 18 | 19 | /** Access the ambient {@link Keymap} */ 20 | export function useKeymap() { 21 | return useContext(KeymapContext); 22 | } 23 | -------------------------------------------------------------------------------- /packages/keymap/tests/keymap.test.ts: -------------------------------------------------------------------------------- 1 | import {Keymap} from "../src/index"; 2 | 3 | /* Modifier keys cannot be tested in Keymap::identify and Keymap.handle 4 | due to a bug in jsdom: https://github.com/jsdom/jsdom/issues/3126 5 | */ 6 | 7 | test("Keymap::identify", () => { 8 | const e = new KeyboardEvent("keyup", {key: "a", code: "KeyA"}); 9 | expect(Keymap.identify(e)).toBe("A"); 10 | }); 11 | 12 | test("Keymap::normalize", () => { 13 | expect(Keymap.normalize("A+Shift+Ctrl")).toBe("Ctrl+Shift+A"); 14 | expect(Keymap.normalize("q+alt+ctrl")).toBe("Ctrl+Alt+Q"); 15 | }); 16 | 17 | describe("Keymap bind handling", () => { 18 | const keymap = new Keymap(); 19 | 20 | const cb = jest.fn(); 21 | const cb2 = jest.fn(); 22 | 23 | keymap.bind("A", cb); 24 | keymap.bind("B", cb2); 25 | 26 | test("getHandlers", () => { 27 | expect(keymap.getHandlers("A")).toEqual([cb]); 28 | expect(keymap.getHandlers("B")).toEqual([cb2]); 29 | }); 30 | 31 | test("getKeys", () => { 32 | expect(keymap.getKeys()).toEqual(["A", "B"]); 33 | }); 34 | 35 | test("unbind", () => { 36 | expect(() => keymap.unbind("C", cb)).toThrow("C is not bound"); 37 | expect(() => keymap.unbind("B", cb)).toThrow(`B is not bound to ${cb.name}`); 38 | keymap.unbind("A", cb); 39 | expect(keymap.getHandlers("A")).toEqual([]); 40 | }); 41 | 42 | test("handle", () => { 43 | const e = new KeyboardEvent("keyup", {key: "B", code: "KeyB"}); 44 | keymap.handle(e); 45 | expect(cb2).toHaveBeenCalledTimes(1); 46 | expect(cb2).toHaveBeenCalledWith(e); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/keymap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "declarationDir": "./dist/types", 6 | "outDir": "./dist", 7 | "rootDir": "./src" 8 | }, 9 | "include": ["./src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/magic/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@yuri", 3 | "rules": { 4 | "quotes": ["error", "double", {"allowTemplateLiterals": true, "avoidEscape": true }] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/magic/LICENSE: -------------------------------------------------------------------------------- 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. 10 | -------------------------------------------------------------------------------- /packages/magic/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/magic 2 | 3 | This package provides template macros for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/cli/macros/ for documentation. 4 | -------------------------------------------------------------------------------- /packages/magic/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jsdom", 4 | testPathIgnorePatterns: ["dist"], 5 | coverageReporters: ["json-summary"], 6 | transform: {} 7 | }; 8 | -------------------------------------------------------------------------------- /packages/magic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/magic", 3 | "version": "1.1.1", 4 | "description": "Templating functions for Liqvid", 5 | "main": "./dist/index.js", 6 | "typings": "./dist/index.d.ts", 7 | "files": [ 8 | "dist/*" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/liqvidjs/liqvid.git" 13 | }, 14 | "author": "Yuri Sulyma ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/liqvidjs/liqvid/issues" 18 | }, 19 | "scripts": { 20 | "build": "tsc --build --force", 21 | "lint": "eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests", 22 | "test": "jest" 23 | }, 24 | "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/magic#readme", 25 | "sideEffects": false, 26 | "devDependencies": { 27 | "@types/jest": "^27.4.0", 28 | "@typescript-eslint/eslint-plugin": "^5.10.1", 29 | "@typescript-eslint/parser": "^5.10.1", 30 | "@yuri/eslint-config": "^1.0.1", 31 | "eslint": "^8.8.0", 32 | "jest": "^27.4.7", 33 | "ts-jest": "^27.1.3", 34 | "typescript": "^4.5.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/magic/src/default-assets.ts: -------------------------------------------------------------------------------- 1 | import type {ScriptData, StyleData} from "./types"; 2 | 3 | export const scripts: Record = { 4 | "host": "https://unpkg.com/@liqvid/host/lv-host.js", 5 | "liqvid": { 6 | "crossorigin": true, 7 | "development": "https://unpkg.com/liqvid@2.1.3/dist/liqvid.js", 8 | "production": "https://unpkg.com/liqvid@2.1.3/dist/liqvid.min.js", 9 | "integrity": "sha384-PF1Q6/ZHWULtuwe8ef5LK49usEuK4uCYtOM8l+u4Wu0hpZw5r0WDgDe9slKjNIwj" 10 | }, 11 | "livereload": {}, 12 | "polyfills": "https://unpkg.com/@liqvid/polyfills/dist/waapi.js", 13 | "rangetouch": { 14 | "crossorigin": true, 15 | "development": "https://cdn.rangetouch.com/2.0.1/rangetouch.js", 16 | "integrity": "sha384-ImWMbbJ1rSn1mn+2vsKm/wN6Vc7hPNB2VKN0lX3FAzGK+c7M2mD6ZZcwknuKlP7K", 17 | "production": "https://cdn.rangetouch.com/2.0.1/rangetouch.js" 18 | }, 19 | "react": { 20 | "crossorigin": true, 21 | "development": "https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.development.js", 22 | "integrity": "sha384-YF0qbrX3+TW1Oyow2MYZpkEMq34QcYzbTJbSb9K0sdeykm4i4kTCSrsYeH8HX11w", 23 | "production": "https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js" 24 | }, 25 | "react-dom": { 26 | "crossorigin": true, 27 | "development": "https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.development.js", 28 | "production": "https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js", 29 | "integrity": "sha384-DHlzXk2aXirrhqAkoaI5lzdgwWB07jUHz7DJGmS4Vlvt5U/ztRy+Yr8oSgQw5QaE" 30 | }, 31 | "recording": { 32 | "crossorigin": true, 33 | "development": "https://unpkg.com/rp-recording@2.1.1/dist/rp-recording.js" 34 | } 35 | }; 36 | 37 | export const styles: Record = { 38 | "liqvid": { 39 | "development": "https://unpkg.com/liqvid@2.1.3/dist/liqvid.css", 40 | "production": "https://unpkg.com/liqvid@2.1.3/dist/liqvid.min.css" 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /packages/magic/src/index.ts: -------------------------------------------------------------------------------- 1 | import type {ScriptData, StyleData} from "./types"; 2 | export type {ScriptData, StyleData} from "./types"; 3 | 4 | /** 5 | * Template function. 6 | */ 7 | export function transform(content: string, config: { 8 | mode: "development" | "production"; 9 | scripts: Record; 10 | styles: Record; 11 | }) { 12 | // insert scripts 13 | content = content.replaceAll(//g, (match, label: string) => { 14 | const script = config.scripts[label]; 15 | if (!script) { 16 | console.warn(`Missing script ${label}`); 17 | return match; 18 | } 19 | 20 | if (typeof script === "string") { 21 | return tag("script", {src: script}); 22 | } else { 23 | const handler = script[config.mode]; 24 | if (!handler) { 25 | return ""; 26 | } 27 | 28 | if (typeof handler === "string") { 29 | const attrs: Record = {}; 30 | 31 | if (script.crossorigin) { 32 | attrs.crossorigin = "anonymous";//script.crossorigin; 33 | } 34 | 35 | if (config.mode === "production" && script.integrity) { 36 | attrs.integrity = script.integrity; 37 | } 38 | 39 | attrs.src = handler; 40 | 41 | return tag("script", attrs); 42 | } else { 43 | return tag("script", {}, handler); 44 | } 45 | } 46 | }); 47 | 48 | // insert styles 49 | content = content.replaceAll(//g, (match, label: string) => { 50 | const style = config.styles[label]; 51 | if (!style) { 52 | console.warn(`Missing style ${label}`); 53 | return match; 54 | } 55 | 56 | const attrs: Record = { 57 | rel: "stylesheet", 58 | type: "text/css" 59 | }; 60 | 61 | if (typeof style === "string") { 62 | return tag("link", {href: style, ...attrs}, true); 63 | } else { 64 | const handler = style[config.mode]; 65 | if (!handler) { 66 | return ""; 67 | } 68 | 69 | if (typeof handler === "string") { 70 | return tag("link", {href: style[config.mode], ...attrs}, true); 71 | } 72 | } 73 | 74 | return tag("link", attrs, true); 75 | }); 76 | 77 | // insert json 78 | content = content.replaceAll(//g, (match, label: string, src: string) => { 79 | return tag("link", { 80 | as: "fetch", 81 | "data-name": label, 82 | href: src, 83 | rel: "preload", 84 | type: "application/json" 85 | }, true); 86 | }); 87 | 88 | // return 89 | return content; 90 | } 91 | 92 | /** 93 | * Create an HTML tag. 94 | */ 95 | export function tag( 96 | tagName: K, 97 | attrs: Record = {}, 98 | nextOrClose: boolean | number | string | (() => string) = false 99 | ) { 100 | const close = (nextOrClose === true); 101 | 102 | const attrString = Object.keys(attrs) 103 | .map(attr => { 104 | if (!attrs.hasOwnProperty(attr)) return ""; 105 | 106 | if ("boolean" === typeof attrs[attr]) { 107 | if (attrs[attr]) return ` ${attr}`; 108 | return ""; 109 | } 110 | 111 | // XXX make sure this is correct escaping 112 | const escaped = attrs[attr].toString().replace(/"/g, """); 113 | 114 | return ` ${attr}="${escaped}"`; 115 | }) 116 | .join(""); 117 | 118 | const str = `<${tagName}${attrString}`; 119 | 120 | if (close) return `${str}/>`; 121 | 122 | let content; 123 | switch (typeof nextOrClose) { 124 | case "function": 125 | content = nextOrClose(); 126 | break; 127 | case "number": 128 | case "string": 129 | content = nextOrClose; 130 | break; 131 | default: 132 | content = ""; 133 | break; 134 | } 135 | 136 | return `${str}>${content}`; 137 | } 138 | 139 | export {scripts, styles} from "./default-assets"; 140 | -------------------------------------------------------------------------------- /packages/magic/src/types.ts: -------------------------------------------------------------------------------- 1 | export type ScriptData = { 2 | /** 3 | * Whether script is crossorigin. 4 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-crossorigin 5 | */ 6 | crossorigin?: boolean | string; 7 | 8 | /** 9 | * Whether to apply the defer attribute 10 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-defer 11 | */ 12 | defer?: boolean; 13 | 14 | /** 15 | * Development src. 16 | */ 17 | development?: string | (() => string); 18 | 19 | /** 20 | * Integrity attribute for production. 21 | */ 22 | integrity?: string; 23 | 24 | /** 25 | * Production src. 26 | */ 27 | production?: string | (() => string); 28 | } | string; 29 | 30 | export type StyleData = { 31 | /** 32 | * Development href. 33 | */ 34 | development?: string; 35 | 36 | /** 37 | * Production href. 38 | */ 39 | production?: string; 40 | } | string; 41 | -------------------------------------------------------------------------------- /packages/magic/tests/magic.test.ts: -------------------------------------------------------------------------------- 1 | import exp from "constants"; 2 | import {tag, transform, scripts, styles} from ".."; 3 | 4 | jest.spyOn(console, "warn").mockImplementation(() => {}); 5 | 6 | // describe("default assets", () => { 7 | 8 | // }); 9 | 10 | test("@json", () => { 11 | const content = ``; 12 | const str = transform(content, {mode: "development", scripts: {}, styles: {}}); 13 | 14 | expect(str).toBe(``); 15 | }); 16 | 17 | describe("@script", () => { 18 | const config = { 19 | scripts: { 20 | "basic": { 21 | "development": "https://dev.com", 22 | "production": "https://prod.com" 23 | }, 24 | "devOnly": { 25 | "development": "https://dev.only" 26 | }, 27 | "prodOnly": { 28 | "production": "https://prod.only" 29 | }, 30 | "single": "https://same-url.com", 31 | "withIntegrity": { 32 | "crossorigin": "anonymous", 33 | "defer": true, 34 | "integrity": "sha384", 35 | "development": "https://dev.com", 36 | "production": "https://prod.com" 37 | } 38 | }, 39 | styles: {} 40 | }; 41 | 42 | test("single script", () => { 43 | const content = ``; 44 | expect(transform(content, {mode: "development", ...config})).toBe(``); 45 | expect(transform(content, {mode: "production", ...config})).toBe(``); 46 | }) 47 | 48 | test("mode selection", () => { 49 | const content = ``; 50 | expect(transform(content, {mode: "development", ...config})).toBe(``); 51 | expect(transform(content, {mode: "production", ...config})).toBe(``); 52 | }); 53 | 54 | test("integrity attribute", () => { 55 | const content = ``; 56 | expect(transform(content, {mode: "development", ...config})).toBe(``); 57 | expect(transform(content, {mode: "production", ...config})).toBe(``); 58 | }); 59 | 60 | test("complain about missing script", () => { 61 | const content = ``; 62 | expect(transform(content, {mode: "development", ...config})).toBe(content); 63 | expect(console.warn).toHaveBeenCalledWith("Missing script missing"); 64 | expect(transform(content, {mode: "production", ...config})).toBe(content); 65 | expect(console.warn).toHaveBeenCalledWith("Missing script missing"); 66 | }); 67 | 68 | test("dev only", () => { 69 | const content = ``; 70 | expect(transform(content, {mode: "development", ...config})).toBe(``); 71 | expect(transform(content, {mode: "production", ...config})).toBe(""); 72 | }); 73 | 74 | test("prod only", () => { 75 | const content = ``; 76 | expect(transform(content, {mode: "development", ...config})).toBe(""); 77 | expect(transform(content, {mode: "production", ...config})).toBe(``); 78 | }); 79 | }); 80 | 81 | describe("@styles", () => { 82 | const config = { 83 | scripts: {}, 84 | styles: { 85 | "basic": { 86 | "development": "https://dev.com", 87 | "production": "https://prod.com" 88 | }, 89 | "devOnly": { 90 | "development": "https://dev.only" 91 | }, 92 | "prodOnly": { 93 | "production": "https://prod.only" 94 | }, 95 | "single": "https://same-url.com", 96 | "withIntegrity": { 97 | "crossorigin": "anonymous", 98 | "defer": true, 99 | "integrity": "sha384", 100 | "development": "https://dev.com", 101 | "production": "https://prod.com" 102 | } 103 | } 104 | }; 105 | 106 | test("single style", () => { 107 | const content = ``; 108 | expect(transform(content, {mode: "development", ...config})).toBe(``); 109 | expect(transform(content, {mode: "production", ...config})).toBe(``); 110 | }) 111 | 112 | test("mode selection", () => { 113 | const content = ``; 114 | expect(transform(content, {mode: "development", ...config})).toBe(``); 115 | expect(transform(content, {mode: "production", ...config})).toBe(``); 116 | }) 117 | 118 | test("complain about missing style", () => { 119 | const content = ``; 120 | expect(transform(content, {mode: "development", ...config})).toBe(content); 121 | expect(console.warn).toHaveBeenCalledWith("Missing style missing"); 122 | expect(transform(content, {mode: "production", ...config})).toBe(content); 123 | expect(console.warn).toHaveBeenCalledWith("Missing style missing"); 124 | }); 125 | 126 | test("dev only", () => { 127 | const content = ``; 128 | expect(transform(content, {mode: "development", ...config})).toBe(``); 129 | expect(transform(content, {mode: "production", ...config})).toBe(""); 130 | }); 131 | 132 | test("prod only", () => { 133 | const content = ``; 134 | expect(transform(content, {mode: "development", ...config})).toBe(""); 135 | expect(transform(content, {mode: "production", ...config})).toBe(``); 136 | }); 137 | }); 138 | 139 | describe("tag", () => { 140 | test("no args", () => { 141 | expect(tag("p")).toBe(`

`); 142 | }); 143 | 144 | test("attrs", () => { 145 | expect(tag("script", {src: "test.js", type: "text/javascript"})).toBe(``); 146 | }); 147 | 148 | test("boolean attribute", () => { 149 | expect(tag("script", {crossorigin: true, src: "test.js"})).toBe(``); 150 | }); 151 | 152 | test("function content", () => { 153 | expect(tag("a", {href: "test.html"}, () => "Click Here")).toBe(`Click Here`); 154 | }); 155 | 156 | test("escape quotes", () => { 157 | expect(tag("span", {title: `"this is a test"`}, "Hello")).toBe(`Hello`); 158 | }); 159 | 160 | test("self-closing tag", () => { 161 | expect(tag("link", {href: "test.css"}, true)).toBe(``); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /packages/magic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "module": "none" 8 | }, 9 | "include": ["./src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/mathjax/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@yuri", 3 | "rules": { 4 | "@typescript-eslint/no-empty-function": ["off"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/mathjax/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | dist 5 | -------------------------------------------------------------------------------- /packages/mathjax/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/mathjax 2 | 3 | [MathJax](https://mathjax.org/) plugin for [Liqvid](https://liqvidjs.org). 4 | 5 | ## Usage 6 | 7 | ```tsx 8 | import {MJX} from "@liqvid/mathjax"; 9 | 10 | function Quadratic() { 11 | return ( 12 |
13 | The value of x is given by the quadratic formula 14 | {String.raw`x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}`} 15 |
16 | ); 17 | } 18 | ``` 19 | -------------------------------------------------------------------------------- /packages/mathjax/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | tsc --module esnext --outDir dist/esm --target esnext 3 | rollup main.js --file bundle.js --format iife 4 | -------------------------------------------------------------------------------- /packages/mathjax/build.ts: -------------------------------------------------------------------------------- 1 | import {rollup} from "rollup"; 2 | import {promises as fsp} from "fs"; 3 | import * as path from "path"; 4 | 5 | const external = ["liqvid", "react", "react/jsx-runtime"]; 6 | 7 | async function build() { 8 | const bundle = await rollup({ 9 | external, 10 | input: "./dist/esm/index.js", 11 | plugins: [ 12 | // @ts-ignore 13 | // outputPlugin(compiled, ".js", pkg.lezer ? (await import("@lezer/generator/rollup")).lezer() : {name: "dummy"}) 14 | ] 15 | }) 16 | const result = await bundle.generate({ 17 | format: "esm" 18 | }); 19 | 20 | for (const file of result.output) { 21 | let content = (file as any).code || (file as any).source; 22 | await fsp.writeFile(path.join("test", file.fileName), content) 23 | } 24 | } 25 | 26 | build(); 27 | -------------------------------------------------------------------------------- /packages/mathjax/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/mathjax", 3 | "version": "1.0.0", 4 | "description": "MathJax integration for Liqvid", 5 | "exports": { 6 | ".": "./dist/index.js" 7 | }, 8 | "typesVersions": { 9 | "*": { 10 | "*": [ 11 | "./dist/*" 12 | ] 13 | } 14 | }, 15 | "files": [ 16 | "dist/*" 17 | ], 18 | "author": "Yuri Sulyma ", 19 | "keywords": [ 20 | "liqvid", 21 | "mathjax" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/ysulyma/rp-mathjax.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/ysulyma/rp-mathjax/issues" 29 | }, 30 | "homepage": "https://github.com/ysulyma/rp-mathjax#readme", 31 | "license": "MIT", 32 | "peerDependencies": { 33 | "@types/react": "^17.0.19", 34 | "liqvid": "2.1.0-beta.3", 35 | "mathjax": "^3.2.0", 36 | "react": "^17.0.2" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.17.5", 40 | "@types/jest": "^27.4.1", 41 | "@types/node": "^17.0.21", 42 | "@types/react": "^17.0.39", 43 | "@typescript-eslint/eslint-plugin": "^5.14.0", 44 | "@typescript-eslint/parser": "^5.14.0", 45 | "@yuri/eslint-config": "^1.0.1", 46 | "eslint": "^8.10.0", 47 | "eslint-plugin-react": "^7.29.3", 48 | "jest": "^27.5.1", 49 | "liqvid": "2.1.0-beta.3", 50 | "mathjax": "^3.2.0", 51 | "react": "^17.0.2", 52 | "rollup": "^2.70.0", 53 | "ts-jest": "^27.1.3", 54 | "ts-node": "^10.4.0", 55 | "typescript": "^4.6.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/mathjax/src/Blocking.tsx: -------------------------------------------------------------------------------- 1 | import {Player} from "liqvid"; 2 | 3 | import {MJXNonBlocking, MJXTextNonBlocking} from "./NonBlocking"; 4 | 5 | export class MJXBlocking extends MJXNonBlocking { 6 | static contextType = Player.Context; 7 | context: Player; 8 | 9 | async componentDidMount() { 10 | const player = this.context; 11 | 12 | player.obstruct("canplay", this.ready); 13 | player.obstruct("canplaythrough", this.ready); 14 | 15 | super.componentDidMount(); 16 | } 17 | } 18 | 19 | export class MJXTextBlocking extends MJXTextNonBlocking { 20 | static contextType = Player.Context; 21 | context: Player; 22 | 23 | async componentDidMount() { 24 | const player = this.context; 25 | 26 | player.obstruct("canplay", this.ready); 27 | player.obstruct("canplaythrough", this.ready); 28 | 29 | super.componentDidMount(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/mathjax/src/index.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | // alas: https://github.com/mathjax/MathJax/issues/2197#issuecomment-531566828 3 | const MathJax: any 4 | } 5 | 6 | export {Handle} from "./plain"; 7 | export * from "./NonBlocking"; 8 | 9 | // import {MJXBlocking, MJXTextBlocking} from "./Blocking"; 10 | // export { 11 | // MJXBlocking as MJX, MJXBlocking, 12 | // MJXTextBlocking as MJXText, MJXTextBlocking 13 | // }; 14 | -------------------------------------------------------------------------------- /packages/mathjax/src/plain.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {forwardRef, useEffect, useImperativeHandle, useMemo, useRef} from "react"; 3 | 4 | export interface Handle { 5 | /** Underlying or element. */ 6 | domElement: HTMLElement; 7 | ready?: Promise; 8 | } 9 | 10 | interface Props { 11 | /** 12 | * If true, will render using typesetPromise() 13 | * @default false 14 | */ 15 | async?: boolean; 16 | 17 | /** Display mode or inline */ 18 | display?: boolean; 19 | 20 | /** Whether to rerender on resize (necessary for XyJax) */ 21 | resize?: boolean; 22 | 23 | /** 24 | * Whether to wrap in a element or insert directly 25 | * @default false 26 | */ 27 | span?: boolean; 28 | } 29 | 30 | const implementation: React.ForwardRefRenderFunction = function MJX(props, ref) { 31 | const { 32 | children, 33 | async = false, display = false, resize = false, span = false, 34 | ...attrs 35 | } = props; 36 | 37 | const spanRef = useRef(); 38 | const [ready, resolve] = usePromise(); 39 | 40 | /* typeset */ 41 | useEffect(() => { 42 | MathJax.startup.promise.then(() => { 43 | MathJax.typeset([spanRef.current]); 44 | 45 | // replace wrapper span with content 46 | if (false && !span) { 47 | const element = spanRef.current.firstElementChild as HTMLElement; 48 | spanRef.current.replaceWith(element); 49 | spanRef.current = element; 50 | } 51 | 52 | resolve(); 53 | }); 54 | }, []); 55 | 56 | // handle 57 | useImperativeHandle(ref, () => ({ 58 | get domElement() { 59 | return spanRef.current; 60 | }, 61 | ready 62 | })); 63 | 64 | const [open, close] = display ? ["\\[", "\\]"] : ["\\(", "\\)"]; 65 | 66 | // Google Chrome fails without this 67 | // if (display) { 68 | // if (!attrs.style) 69 | // attrs.style = {}; 70 | // attrs.style.display = "block"; 71 | // } 72 | 73 | return ( 74 | {open + children + close} 75 | ); 76 | }; 77 | 78 | export const MJX = forwardRef(implementation); 79 | 80 | // export function typeset(code: string) { 81 | // MathJax.startup.promise = MathJax.startup.promise.then(() => MathJax.typesetPromise(code())) 82 | // .catch((err) => console.log('Typeset failed: ' + err.message)); 83 | // return MathJax.startup.promise; 84 | // } 85 | 86 | function usePromise(deps: React.DependencyList = []): [Promise, () => void] { 87 | const resolveRef = useRef<() => void>(); 88 | const promise = useMemo(() => new Promise((resolve) => { 89 | resolveRef.current = resolve; 90 | }), []); 91 | 92 | return [promise, resolveRef.current]; 93 | } -------------------------------------------------------------------------------- /packages/mathjax/test/index.js: -------------------------------------------------------------------------------- 1 | import { jsx } from 'react/jsx-runtime'; 2 | import { Utils, usePlayer } from 'liqvid'; 3 | import { forwardRef, useRef, useEffect, useImperativeHandle, useMemo } from 'react'; 4 | 5 | const implementation = function MJX(props, ref) { 6 | const { children, async = false, display = false, resize = false, span = false, ...attrs } = props; 7 | const spanRef = useRef(); 8 | const [ready, resolve] = usePromise(); 9 | useEffect(() => { 10 | MathJax.startup.promise.then(() => { 11 | MathJax.typeset([spanRef.current]); 12 | resolve(); 13 | }); 14 | }, []); 15 | useImperativeHandle(ref, () => ({ 16 | get domElement() { 17 | return spanRef.current; 18 | }, 19 | ready 20 | })); 21 | const [open, close] = display ? ["\\[", "\\]"] : ["\\(", "\\)"]; 22 | return (jsx("span", { ...attrs, ref: spanRef, children: open + children + close })); 23 | }; 24 | const MJX$1 = forwardRef(implementation); 25 | function usePromise(deps = []) { 26 | const resolveRef = useRef(); 27 | const promise = useMemo(() => new Promise((resolve) => { 28 | resolveRef.current = resolve; 29 | }), []); 30 | return [promise, resolveRef.current]; 31 | } 32 | 33 | const { combineRefs } = Utils.react; 34 | const MJX = forwardRef(function MJX(props, ref) { 35 | const { reparse = false, ...attrs } = props; 36 | const plain = useRef(); 37 | const combined = combineRefs(plain, ref); 38 | const player = usePlayer(); 39 | useEffect(() => { 40 | if (reparse) { 41 | plain.current.ready.then(() => player.reparseTree(plain.current.domElement)); 42 | } 43 | }, []); 44 | return (jsx(MJX$1, { ref: combined, ...attrs })); 45 | }); 46 | 47 | export { MJX }; 48 | -------------------------------------------------------------------------------- /packages/mathjax/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "jsx": "react-jsx", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "outDir": "./dist", 10 | "pretty": true, 11 | "removeComments": true, 12 | "suppressImplicitAnyIndexErrors": true, 13 | "target": "ES2017" 14 | }, 15 | "files": ["./src/index.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/playback/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/playback 2 | 3 | This package provides the `Playback` class, which is effectively an animation loop + event emitter pretending to be an HTML media element advancing in time. This is the "engine" at the heart of [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/reference/Playback for documentation. 4 | -------------------------------------------------------------------------------- /packages/playback/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/playback", 3 | "version": "1.1.2", 4 | "description": "Playback class for Liqvid", 5 | "exports": { 6 | ".": { 7 | "import": "./dist/esm/index.mjs", 8 | "require": "./dist/cjs/index.cjs" 9 | }, 10 | "./react": { 11 | "import": "./dist/esm/react.mjs", 12 | "require": "./dist/cjs/react.cjs" 13 | } 14 | }, 15 | "typesVersions": { 16 | "*": { 17 | "*": [ 18 | "./dist/types/*" 19 | ] 20 | } 21 | }, 22 | "files": [ 23 | "dist/*" 24 | ], 25 | "scripts": { 26 | "build": "npm=$npm_execpath; $npm build:clean && $npm build:cjs && $npm build:esm && $npm build:postclean", 27 | "build:clean": "rm -rf dist", 28 | "build:cjs": "npm=$npm_execpath; $npm build:cjs:tsc && $npm build:cjs:rename", 29 | "build:cjs:tsc": "tsc --module commonjs --outDir dist/cjs", 30 | "build:cjs:rename": "for i in ./dist/cjs/*.js; do mv -- \"$i\" \"${i%.js}.cjs\"; done", 31 | "build:esm": "npm=$npm_execpath; $npm build:esm:tsc && $npm build:esm:fix", 32 | "build:esm:tsc": "tsc --module esnext --outDir dist/esm", 33 | "build:esm:fix": "node ../../build.mjs", 34 | "build:postclean": "find . -name tsconfig.tsbuildinfo -delete", 35 | "lint": "eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/liqvidjs/liqvid.git" 40 | }, 41 | "author": "Yuri Sulyma ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/liqvidjs/liqvid/issues" 45 | }, 46 | "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/playback#readme", 47 | "devDependencies": { 48 | "@babel/core": "^7.17.5", 49 | "@types/jest": "^27.4.0", 50 | "@typescript-eslint/eslint-plugin": "^5.10.1", 51 | "@typescript-eslint/parser": "^5.10.1", 52 | "babel-jest": "^27.4.6", 53 | "eslint": "^8.8.0", 54 | "jest": "^27.4.7", 55 | "react": "^17.0.2", 56 | "ts-jest": "^27.1.3", 57 | "typescript": "^4.4.2" 58 | }, 59 | "dependencies": { 60 | "@liqvid/utils": "workspace:*", 61 | "@lqv/playback": "^0.0.1", 62 | "@types/events": "^3.0.0", 63 | "events": "^3.3.0", 64 | "strict-event-emitter-types": "^2.0.0" 65 | }, 66 | "sideEffects": false, 67 | "peerDependencies": { 68 | "react": "^17.0.2" 69 | }, 70 | "peerDependenciesMeta": { 71 | "react": { 72 | "optional": true 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/playback/src/animation.ts: -------------------------------------------------------------------------------- 1 | import {Playback as CorePlayback} from "./core"; 2 | 3 | declare global { 4 | interface Animation { 5 | /** 6 | * Explicitly persists an animation, when it would otherwise be removed due to the browser's 7 | * [Automatically removing filling animations](https://developer.mozilla.org/en-US/docs/Web/API/Animation#automatically_removing_filling_animations) behavior. 8 | */ 9 | persist(): void; 10 | } 11 | } 12 | 13 | /** Extended {@link CorePlayback Playback} supporting the Web Animation API */ 14 | export class Playback extends CorePlayback { 15 | private __animations: Animation[] = []; 16 | private __delays = new WeakMap(); 17 | 18 | /** {@link DocumentTimeline} synced up to this playback */ 19 | timeline: DocumentTimeline; 20 | 21 | constructor(options: ConstructorParameters[0]) { 22 | super(options); 23 | 24 | this.__createTimeline(); 25 | } 26 | 27 | /** 28 | * Create an {@link Animation} (factory) synced to this playback 29 | * @param keyframes A [keyframes object](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Keyframe_Formats) or `null` 30 | * @param options Either an integer representing the animation's duration (in milliseconds), or {@link KeyframeEffectOptions} 31 | * @returns A callback to attach the animation to a target 32 | */ 33 | newAnimation( 34 | keyframes: Keyframe[] | PropertyIndexedKeyframes, 35 | options?: number | KeyframeEffectOptions 36 | ): (target: T) => Animation { 37 | let anim: Animation; 38 | 39 | return (target: T) => { 40 | if (target === null) { 41 | anim.cancel(); 42 | anim = undefined; 43 | return; 44 | } else if (anim !== undefined) { 45 | console.warn("Animations should not be reused as they will not cancel properly. Check animations attached to ", target); 46 | } 47 | 48 | // create animation 49 | anim = new Animation(new KeyframeEffect(target, keyframes, options), this.timeline); 50 | if (typeof options === "object" && (options.fill === "forwards" || options.fill === "both")) { 51 | anim.persist(); 52 | } 53 | /* adopt animation */ 54 | const delay = anim.effect.getTiming().delay; 55 | this.__delays.set(anim.effect, delay); 56 | 57 | anim.currentTime = (this.currentTime - delay) / this.playbackRate; 58 | anim.startTime = null; 59 | anim.pause(); 60 | 61 | if (delay !== 0) { 62 | anim.effect.updateTiming({delay: 0.1}); 63 | } 64 | 65 | this.__animations.push(anim); 66 | anim.addEventListener("cancel", () => { 67 | this.__animations.splice(this.__animations.indexOf(anim), 1); 68 | }); 69 | 70 | // return 71 | return anim; 72 | }; 73 | } 74 | 75 | /** 76 | * Create our timeline 77 | * 78 | * @listens pause 79 | * @listens play 80 | * @listens ratechange 81 | * @listens seek 82 | */ 83 | private __createTimeline() { 84 | this.timeline = new DocumentTimeline(); 85 | 86 | // pause 87 | this.on("pause", () => { 88 | for (const anim of this.__animations) { 89 | anim.pause(); 90 | } 91 | }); 92 | 93 | // play 94 | this.on("play", () => { 95 | for (const anim of this.__animations) { 96 | anim.startTime = null; 97 | anim.play(); 98 | anim.startTime = 99 | this.timeline.currentTime + 100 | (this.__delays.get(anim.effect) - this.currentTime) / this.playbackRate; 101 | } 102 | }); 103 | 104 | // ratechange 105 | this.on("ratechange", () => { 106 | for (const anim of this.__animations) { 107 | anim.playbackRate = this.playbackRate; 108 | } 109 | }); 110 | 111 | // seek 112 | this.on("seek", () => { 113 | for (const anim of this.__animations) { 114 | const offset = (this.__delays.get(anim.effect) - this.currentTime) / this.playbackRate; 115 | if (this.paused) { 116 | // anim.startTime = this.timeline.currentTime + offset 117 | anim.currentTime = -offset; 118 | anim.pause(); 119 | } else { 120 | anim.startTime = this.timeline.currentTime + offset; 121 | } 122 | } 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /packages/playback/src/core.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | import type StrictEventEmitter from "strict-event-emitter-types"; 3 | import {bind, constrain} from "@liqvid/utils/misc"; 4 | 5 | interface PlaybackEvents { 6 | "bufferupdate": void; 7 | "cuechange": void; 8 | "durationchange": void; 9 | "pause": void; 10 | "play": void; 11 | "seek": number; 12 | "seeked": void; 13 | "seeking": void; 14 | "stop": void; 15 | "ratechange": void; 16 | "timeupdate": number; 17 | "volumechange": void; 18 | } 19 | 20 | declare let webkitAudioContext: typeof AudioContext; 21 | 22 | /** 23 | * Class pretending to be a media element advancing in time. 24 | * 25 | * Imitates {@link HTMLMediaElement} to a certain extent, although it does not implement that interface. 26 | */ 27 | export class Playback 28 | extends (EventEmitter as unknown as new () => StrictEventEmitter) 29 | { 30 | /** Audio context owned by this playback */ 31 | audioContext: AudioContext; 32 | 33 | /** Audio node owned by this playback */ 34 | audioNode: GainNode; 35 | 36 | /** 37 | The current playback time in milliseconds. 38 | 39 | **Warning:** {@link HTMLMediaElement.currentTime} measures this property in *seconds*. 40 | */ 41 | currentTime = 0; 42 | 43 | /** Flag indicating whether playback is currently paused. */ 44 | paused = true; 45 | 46 | /* private fields */ 47 | private __playingFrom: number; 48 | private __startTime: number; 49 | 50 | /* private fields exposed by getters */ 51 | private __captions: DocumentFragment[] = []; 52 | private __duration: number; 53 | private __playbackRate = 1; 54 | private __muted = false; 55 | private __seeking = false; 56 | private __volume = 1; 57 | 58 | constructor(options: { 59 | /** Duration of the playback in milliseconds */ 60 | duration: number; 61 | }) { 62 | super(); 63 | 64 | this.duration = options.duration; 65 | this.__playingFrom = 0; 66 | this.__startTime = performance.now(); 67 | 68 | // audio 69 | this.__initAudio(); 70 | 71 | // we will have lots of listeners, turn off warning 72 | this.setMaxListeners(0); 73 | 74 | // bind 75 | bind(this, ["pause", "play"]); 76 | this.__advance = this.__advance.bind(this); 77 | 78 | // initiate playback loop 79 | requestAnimationFrame(this.__advance); 80 | } 81 | 82 | /* magic properties */ 83 | 84 | /** Gets or sets the current captions */ 85 | get captions() { 86 | return this.__captions; 87 | } 88 | 89 | /** @emits cuechange */ 90 | set captions(captions) { 91 | this.__captions = captions; 92 | 93 | this.emit("cuechange"); 94 | } 95 | 96 | /** 97 | * Length of the playback in milliseconds. 98 | * 99 | * **Warning:** {@link HTMLMediaElement.duration} measures this in *seconds*. 100 | */ 101 | get duration() { 102 | return this.__duration; 103 | } 104 | 105 | /** @emits durationchange */ 106 | set duration(duration) { 107 | if (duration === this.__duration) 108 | return; 109 | 110 | this.__duration = duration; 111 | 112 | this.emit("durationchange"); 113 | } 114 | 115 | /** Gets or sets a flag that indicates whether playback is muted. */ 116 | get muted() { 117 | return this.__muted; 118 | } 119 | 120 | /** @emits volumechange */ 121 | set muted(val) { 122 | if (val === this.__muted) 123 | return; 124 | 125 | this.__muted = val; 126 | 127 | if (this.audioNode) { 128 | if (this.__muted) { 129 | this.audioNode.gain.value = 0; 130 | } else { 131 | this.audioNode.gain.setValueAtTime(this.volume, this.audioContext.currentTime); 132 | } 133 | } 134 | 135 | this.emit("volumechange"); 136 | } 137 | 138 | /** Gets or sets the current rate of speed for the playback. */ 139 | get playbackRate() { 140 | return this.__playbackRate; 141 | } 142 | 143 | /** @emits ratechange */ 144 | set playbackRate(val) { 145 | if (val === this.__playbackRate) 146 | return; 147 | 148 | this.__playbackRate = val; 149 | this.__playingFrom = this.currentTime; 150 | this.__startTime = performance.now(); 151 | this.emit("ratechange"); 152 | } 153 | 154 | /** Gets or sets a flag that indicates whether the playback is currently moving to a new position. */ 155 | get seeking() { 156 | return this.__seeking; 157 | } 158 | 159 | /** 160 | * @emits seeking 161 | * @emits seeked 162 | */ 163 | set seeking(val) { 164 | if (val === this.__seeking) 165 | return; 166 | 167 | this.__seeking = val; 168 | if (this.__seeking) this.emit("seeking"); 169 | else this.emit("seeked"); 170 | } 171 | 172 | /** 173 | * Pause playback. 174 | * 175 | * @emits pause 176 | */ 177 | pause() { 178 | this.paused = true; 179 | this.__playingFrom = this.currentTime; 180 | 181 | this.emit("pause"); 182 | } 183 | 184 | /** 185 | * Start or resume playback. 186 | * 187 | * @emits play 188 | */ 189 | play() { 190 | this.paused = false; 191 | 192 | // this is necessary for currentTime to be correct when playing from stop state 193 | this.currentTime = this.__playingFrom; 194 | this.__startTime = performance.now(); 195 | 196 | this.emit("play"); 197 | } 198 | 199 | /** 200 | * Seek playback to a specific time. 201 | * 202 | * @emits seek 203 | */ 204 | seek(t: number) { 205 | t = constrain(0, t, this.duration); 206 | 207 | this.currentTime = this.__playingFrom = t; 208 | this.__startTime = performance.now(); 209 | 210 | this.emit("seek", t); 211 | } 212 | 213 | /** Gets or sets the volume level for the playback. */ 214 | get volume() { 215 | return this.__volume; 216 | } 217 | 218 | /** @emits volumechange */ 219 | set volume(volume: number) { 220 | this.muted = false; 221 | const prevVolume = this.__volume; 222 | this.__volume = constrain(0, volume, 1); 223 | 224 | if (this.audioNode) { 225 | if (prevVolume === 0 || this.__volume === 0) { 226 | this.audioNode.gain.setValueAtTime(0, this.audioContext.currentTime); 227 | } else { 228 | this.audioNode.gain.exponentialRampToValueAtTime(this.__volume, this.audioContext.currentTime + 2); 229 | } 230 | } 231 | 232 | this.emit("volumechange"); 233 | } 234 | 235 | /** 236 | * Stop playback and reset pointer to start 237 | * 238 | * @emits stop 239 | */ 240 | stop() { 241 | this.paused = true; 242 | this.__playingFrom = 0; 243 | 244 | this.emit("stop"); 245 | } 246 | 247 | /* private methods */ 248 | 249 | /** 250 | * @emits timeupdate 251 | */ 252 | private __advance(t: number) { 253 | // paused 254 | if (this.paused || this.__seeking) { 255 | this.__startTime = t; 256 | } else { 257 | // playing 258 | this.currentTime = this.__playingFrom + Math.max((t - this.__startTime) * this.__playbackRate, 0); 259 | 260 | if (this.currentTime >= this.duration) { 261 | this.currentTime = this.duration; 262 | this.stop(); 263 | } 264 | 265 | this.emit("timeupdate", this.currentTime); 266 | } 267 | 268 | requestAnimationFrame(this.__advance); 269 | } 270 | 271 | /** 272 | * Try to initiate audio 273 | * 274 | * @listens click 275 | * @listens keydown 276 | * @listens touchstart 277 | */ 278 | private __initAudio() { 279 | const requestAudioContext = () => { 280 | try { 281 | this.audioContext = new (window.AudioContext || webkitAudioContext)(); 282 | this.audioNode = this.audioContext.createGain(); 283 | this.audioNode.connect(this.audioContext.destination); 284 | 285 | window.removeEventListener("click", requestAudioContext); 286 | window.removeEventListener("keydown", requestAudioContext); 287 | window.removeEventListener("touchstart", requestAudioContext); 288 | } catch (e) { 289 | // console.log("Failed to create audio context"); 290 | } 291 | } 292 | window.addEventListener("click", requestAudioContext); 293 | window.addEventListener("keydown", requestAudioContext); 294 | window.addEventListener("touchstart", requestAudioContext); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /packages/playback/src/index.ts: -------------------------------------------------------------------------------- 1 | export {Playback} from "./animation"; 2 | export {Playback as CorePlayback} from "./core"; 3 | -------------------------------------------------------------------------------- /packages/playback/src/react.ts: -------------------------------------------------------------------------------- 1 | import {createContext, useContext, useEffect, useRef} from "react"; 2 | import type {Playback} from "."; 3 | 4 | type GlobalThis = { 5 | [symbol]: React.Context; 6 | } 7 | 8 | const symbol = Symbol.for("@lqv/playback"); 9 | 10 | if (!(symbol in globalThis)) { 11 | (globalThis as unknown as GlobalThis)[symbol] = createContext(null); 12 | } 13 | 14 | /** 15 | * {@link React.Context} used to access ambient {@link Playback} 16 | */ 17 | export const PlaybackContext = (globalThis as unknown as GlobalThis)[symbol]; 18 | 19 | /** Access the ambient {@link Playback} */ 20 | export function usePlayback(): Playback { 21 | return useContext(PlaybackContext); 22 | } 23 | 24 | /** Register a callback for time update. */ 25 | export function useTime(callback: (value: number) => void, deps?: React.DependencyList): void; 26 | export function useTime(callback: (value: T) => void, transform?: (t: number) => T, deps?: React.DependencyList): void; 27 | export function useTime(callback: (value: T) => void, transform?: ((t: number) => T) | React.DependencyList, deps?: React.DependencyList): void { 28 | const playback = usePlayback(); 29 | const prev = useRef(); 30 | 31 | useEffect(() => { 32 | const listener = 33 | typeof transform === "function" ? 34 | (t: number) => { 35 | const value = transform(t); 36 | if (value !== prev.current) 37 | callback(value); 38 | prev.current = value; 39 | } : 40 | (t: number & T) => { 41 | if (t !== prev.current) 42 | callback(t); 43 | prev.current = t; 44 | }; 45 | 46 | // subscriptions 47 | playback.on("seek", listener); 48 | playback.on("timeupdate", listener); 49 | 50 | // initial call 51 | listener(playback.currentTime); 52 | 53 | // unsubscriptions 54 | return () => { 55 | playback.off("seek", listener); 56 | playback.off("timeupdate", listener); 57 | }; 58 | }, typeof transform === "function" ? deps : transform); 59 | } 60 | -------------------------------------------------------------------------------- /packages/playback/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "declarationDir": "./dist/types", 6 | "outDir": "./dist", 7 | "rootDir": "./src" 8 | }, 9 | "include": ["./src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/polyfills/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/polyfills", 3 | "version": "0.0.1", 4 | "description": "Polyfills used by Liqvid", 5 | "files": [ 6 | "dist/*" 7 | ], 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/liqvidjs/liqvid.git" 11 | }, 12 | "author": "Yuri Sulyma ", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/liqvidjs/liqvid/issues" 16 | }, 17 | "homepage": "https://github.com/liqvidjs/liqvid#readme", 18 | "sideEffects": true, 19 | "dependencies": { 20 | "pepjs": "^0.5.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/polyfills/src/polyfills.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liqvidjs/liqvid-old/b6486c603d7354a3e97bf76df458429997a564d1/packages/polyfills/src/polyfills.ts -------------------------------------------------------------------------------- /packages/polyfills/src/waapi.js: -------------------------------------------------------------------------------- 1 | (async () => { 2 | const POLYFILL_URL = "https://cdnjs.cloudflare.com/ajax/libs/web-animations/2.3.2/web-animations-next.min.js"; 3 | 4 | /** Polyfill the Web Animations API */ 5 | if (typeof DocumentTimeline !== "undefined") 6 | return; 7 | 8 | document.write(``); 9 | 10 | const script = document.querySelector(`script[src="${POLYFILL_URL}"]`); 11 | 12 | await new Promise((resolve, reject) => { 13 | script.addEventListener("load", resolve); 14 | }); 15 | 16 | // DocumentTimeline 17 | window.DocumentTimeline = function() { 18 | const self = Object.assign(Object.create(Object.getPrototypeOf(document.timeline)), document.timeline); 19 | self.currentTime = 0; 20 | return self; 21 | } 22 | // Animation.persist() 23 | Animation.prototype.persist = () => {}; 24 | 25 | // add to document.timeline 26 | const pause = Animation.prototype.pause; 27 | Animation.prototype.pause = function() { 28 | pause.call(this); 29 | if (!document.timeline._animations.includes(this)) 30 | document.timeline._animations.push(this); 31 | }; 32 | 33 | // getAnimations() 34 | document.getAnimations = () => document.timeline._animations; 35 | 36 | // KeyframeEffect.getTiming() 37 | const timingProps = ["delay", "direction", "duration", "easing", "endDelay", "fill", "iterationStart", "iterations"]; 38 | KeyframeEffect.prototype.getTiming = function() { 39 | const proxy = {}; 40 | for (const prop of timingProps) { 41 | Object.defineProperty(proxy, prop, { get: () => { return this._timing["_" + prop]; } }); 42 | } 43 | return proxy; 44 | }; 45 | 46 | // KeyframeEffect.updateTiming() 47 | KeyframeEffect.prototype.updateTiming = function(o) { 48 | for (const prop of timingProps) { 49 | if (o.hasOwnProperty(prop)) { 50 | this._timing["_" + prop] = o[prop]; 51 | } 52 | } 53 | }; 54 | 55 | // Animation.startTime 56 | Object.defineProperty(Animation.prototype, "startTime", { 57 | set: function(v) { 58 | this.currentTime = -v; 59 | } 60 | }); 61 | })(); 62 | -------------------------------------------------------------------------------- /packages/polyfills/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | pepjs@^0.5.3: 6 | version "0.5.3" 7 | resolved "https://registry.yarnpkg.com/pepjs/-/pepjs-0.5.3.tgz#dc755f03d965c20e4b1bb65e42a03a97c382cfc7" 8 | integrity sha512-5yHVB9OHqKd9fr/OIsn8ss0NgThQ9buaqrEuwr9Or5YjPp6h+WTDKWZI+xZLaBGZCtODTnFtlSHNmhFsq67THg== 9 | -------------------------------------------------------------------------------- /packages/react-three/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@yuri" 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-three/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/react-three 2 | 3 | This provides integration of [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) with [Liqvid](https://liqvidjs.org/). See https://liqvidjs.org/docs/integrations/three/ for examples. 4 | -------------------------------------------------------------------------------- /packages/react-three/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/react-three", 3 | "version": "1.0.1", 4 | "description": "@react-three integration for Liqvid", 5 | "main": "./dist/index.cjs", 6 | "module": "./dist/index.mjs", 7 | "typings": "./dist/index.d.ts", 8 | "files": [ 9 | "dist/*" 10 | ], 11 | "scripts": { 12 | "build": "npm=$npm_execpath; $npm build:clean && $npm build:cjs && $npm build:esm && $npm build:fix-esm", 13 | "build:clean": "rm -fr dist", 14 | "build:cjs": "tsc --module commonjs && mv dist/index.js dist/index.cjs", 15 | "build:esm": "tsc --module esnext && mv dist/index.js dist/index.mjs", 16 | "build:fix-esm": "sed --in-place --expression 's/useContextBridge\"/useContextBridge.js\"/g' ./dist/index.mjs", 17 | "lint": "eslint --ext ts,tsx --fix src", 18 | "test": "echo \"Error: no test specified\" && exit 1" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/liqvidjs/liqvid.git" 23 | }, 24 | "author": "Yuri Sulyma ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/liqvidjs/liqvid/issues" 28 | }, 29 | "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/react-three", 30 | "dependencies": { 31 | "@juggle/resize-observer": "^3.3.1", 32 | "@react-three/drei": "^8.16.5", 33 | "@types/events": "^3.0.0", 34 | "@types/react-reconciler": "^0.26.4" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.17.5", 38 | "@react-three/fiber": "^7.0.26", 39 | "@types/jest": "^27.4.1", 40 | "@types/react": "^17.0.40", 41 | "@types/react-dom": "^17.0.13", 42 | "@types/three": "^0.138.0", 43 | "@yuri/eslint-config": "^1.0.1", 44 | "eslint": "^8.11.0", 45 | "events": "^3.3.0", 46 | "jest": "^27.5.1", 47 | "liqvid": "^2.1.3", 48 | "react": "^17.0.2", 49 | "react-dom": "^17.0.2", 50 | "strict-event-emitter-types": "^2.0.0", 51 | "three": "^0.138.3", 52 | "ts-jest": "^27.1.3", 53 | "typescript": "^4.6.2" 54 | }, 55 | "peerDependencies": { 56 | "@react-three/fiber": "^7.0.0", 57 | "liqvid": "^2.1.3", 58 | "react": "^17.0.0", 59 | "react-dom": "^17.0.0" 60 | }, 61 | "sideEffects": false, 62 | "type": "module" 63 | } 64 | -------------------------------------------------------------------------------- /packages/react-three/src/index.tsx: -------------------------------------------------------------------------------- 1 | import {ResizeObserver} from "@juggle/resize-observer"; 2 | import {useContextBridge} from "@react-three/drei/core/useContextBridge"; 3 | import {Canvas as ThreeCanvas, useThree} from "@react-three/fiber"; 4 | import {Player, PlaybackContext, KeymapContext} from "liqvid"; 5 | import {useEffect} from "react"; 6 | 7 | /** Default affordances: click and arrow keys */ 8 | const defaultAffords = "click keys(ArrowUp,ArrowDown,ArrowLeft,ArrowRight)"; 9 | 10 | /** 11 | * Liqvid-aware Canvas component @react-three/fiber 12 | */ 13 | export function Canvas(props: React.ComponentProps) { 14 | const ContextBridge = useContextBridge(Player.Context, PlaybackContext, KeymapContext); 15 | return ( 16 | 17 | 18 | 19 | {props.children} 20 | 21 | 22 | ); 23 | } 24 | 25 | function Fixes(props: { 26 | "data-affords"?: string; 27 | }): null { 28 | const {gl} = useThree(); 29 | useEffect(() => { 30 | const affords = props["data-affords"] ?? defaultAffords; 31 | if (affords) { 32 | gl.domElement.setAttribute("data-affords", affords); 33 | } 34 | gl.domElement.style.touchAction = "none"; 35 | }, []); 36 | return null; 37 | } 38 | -------------------------------------------------------------------------------- /packages/react-three/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src" 7 | }, 8 | "include": ["./src"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/react", 3 | "version": "0.0.1", 4 | "description": "React surface for Liqvid", 5 | "exports": { 6 | ".": "./dist/index.js" 7 | }, 8 | "typesVersions": { 9 | "*": { 10 | "*": [ 11 | "./dist/*" 12 | ] 13 | } 14 | }, 15 | "files": [ 16 | "dist/*" 17 | ], 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/liqvidjs/liqvid.git" 24 | }, 25 | "author": "Yuri Sulyma ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/liqvidjs/liqvid/issues" 29 | }, 30 | "homepage": "https://github.com/liqvidjs/liqvid#readme", 31 | "dependencies": { 32 | "@types/events": "^3.0.0", 33 | "@types/react-reconciler": "^0.26.3" 34 | }, 35 | "devDependencies": { 36 | "@types/react": "^17.0.14", 37 | "@types/react-dom": "^17.0.9", 38 | "events": "^3.3.0", 39 | "liqvid": "^2.0.10", 40 | "react": "^17.0.2", 41 | "react-dom": "^17.0.2", 42 | "strict-event-emitter-types": "^2.0.0" 43 | }, 44 | "peerDependencies": { 45 | "liqvid": "^2.0.10", 46 | "react": "^17.0.0", 47 | "react-dom": "^17.0.0" 48 | }, 49 | "sideEffects": false 50 | } 51 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | import {useContext, useEffect, useReducer} from "react"; 2 | import {Player} from "liqvid"; 3 | 4 | export function usePlayer() { 5 | return useContext(Player.Context); 6 | } 7 | 8 | export function useKeymap() { 9 | return usePlayer().keymap; 10 | } 11 | 12 | export function usePlayback() { 13 | return usePlayer().playback; 14 | } 15 | 16 | /** 17 | * Register a callback for time update. Returns the current time. 18 | */ 19 | export function useTime(callback: (t: number) => void, deps?: React.DependencyList) { 20 | const {playback} = useContext(Player.Context); 21 | 22 | useEffect(() => { 23 | playback.hub.on("seek", callback); 24 | playback.hub.on("timeupdate", callback); 25 | callback(playback.currentTime); 26 | 27 | return () => { 28 | playback.hub.off("seek", callback); 29 | playback.hub.off("timeupdate", callback); 30 | }; 31 | }, deps); 32 | 33 | return playback.currentTime; 34 | } 35 | 36 | export function combineRefs(...args: React.Ref[]) { 37 | return (o: T) => { 38 | for (const ref of args) { 39 | if (typeof ref === "function") { 40 | ref(o); 41 | } else if (ref === null) { 42 | } else if (typeof ref === "object" && ref.hasOwnProperty("current")) { 43 | (ref as React.MutableRefObject).current = o; 44 | } 45 | } 46 | }; 47 | } 48 | 49 | export function useForceUpdate() { 50 | return useReducer((c: boolean) => !c, false)[1]; 51 | } 52 | -------------------------------------------------------------------------------- /packages/react/src/three.tsx: -------------------------------------------------------------------------------- 1 | import {Canvas, useThree} from "@react-three/fiber"; 2 | import {ResizeObserver} from '@juggle/resize-observer'; 3 | import {Player, usePlayer} from "liqvid"; 4 | 5 | export function ThreeCanvas(props: React.ComponentProps) { 6 | return ( 7 | 8 | 9 | 10 | {props.children} 11 | 12 | 13 | ); 14 | } 15 | 16 | import {useEffect} from "react"; 17 | 18 | function Fixes(): null { 19 | const {gl} = useThree(); 20 | useEffect(() => { 21 | gl.domElement.setAttribute("touch-action", "none"); 22 | gl.domElement.addEventListener("mouseup", Player.preventCanvasClick); 23 | }, []); 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "module": "commonjs" 8 | }, 9 | "include": ["./src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/react/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/events@^3.0.0": 6 | "integrity" "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==" 7 | "resolved" "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz" 8 | "version" "3.0.0" 9 | 10 | "@types/prop-types@*": 11 | "integrity" "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" 12 | "resolved" "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz" 13 | "version" "15.7.4" 14 | 15 | "@types/react-dom@^17.0.0", "@types/react-dom@^17.0.9": 16 | "integrity" "sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==" 17 | "resolved" "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz" 18 | "version" "17.0.9" 19 | dependencies: 20 | "@types/react" "*" 21 | 22 | "@types/react-reconciler@^0.26.3": 23 | "integrity" "sha512-9U6oMpB9pzurW4oqqLVVArPa8OcfnYZV56/O5ktsyxhJc79ijmJjoa5r5JuElRKWCVHM5xW7rIDXf/7AzVp1ow==" 24 | "resolved" "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.3.tgz" 25 | "version" "0.26.3" 26 | dependencies: 27 | "@types/react" "*" 28 | 29 | "@types/react@*", "@types/react@^17.0.0", "@types/react@^17.0.14": 30 | "integrity" "sha512-sX1HisdB1/ZESixMTGnMxH9TDe8Sk709734fEQZzCV/4lSu9kJCPbo2PbTRoZM+53Pp0P10hYVyReUueGwUi4A==" 31 | "resolved" "https://registry.npmjs.org/@types/react/-/react-17.0.19.tgz" 32 | "version" "17.0.19" 33 | dependencies: 34 | "@types/prop-types" "*" 35 | "@types/scheduler" "*" 36 | "csstype" "^3.0.2" 37 | 38 | "@types/scheduler@*": 39 | "integrity" "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" 40 | "resolved" "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz" 41 | "version" "0.16.2" 42 | 43 | "csstype@^3.0.2": 44 | "integrity" "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" 45 | "resolved" "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz" 46 | "version" "3.0.8" 47 | 48 | "events@^3.3.0": 49 | "integrity" "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" 50 | "resolved" "https://registry.npmjs.org/events/-/events-3.3.0.tgz" 51 | "version" "3.3.0" 52 | 53 | "js-tokens@^3.0.0 || ^4.0.0": 54 | "integrity" "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 55 | "resolved" "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" 56 | "version" "4.0.0" 57 | 58 | "liqvid@^2.0.10": 59 | "integrity" "sha512-B5pj/P6RYQdVFAqUplp6c0lW8IdqEeITylmSIj2h5wB9/ff5njhcSgoZ3RjpiNAqbxnOKdna4DqGQVPDtLG9eQ==" 60 | "resolved" "https://registry.npmjs.org/liqvid/-/liqvid-2.0.10.tgz" 61 | "version" "2.0.10" 62 | 63 | "loose-envify@^1.1.0": 64 | "integrity" "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==" 65 | "resolved" "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" 66 | "version" "1.4.0" 67 | dependencies: 68 | "js-tokens" "^3.0.0 || ^4.0.0" 69 | 70 | "object-assign@^4.1.1": 71 | "integrity" "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 72 | "resolved" "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" 73 | "version" "4.1.1" 74 | 75 | "react-dom@^17.0.2": 76 | "integrity" "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==" 77 | "resolved" "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz" 78 | "version" "17.0.2" 79 | dependencies: 80 | "loose-envify" "^1.1.0" 81 | "object-assign" "^4.1.1" 82 | "scheduler" "^0.20.2" 83 | 84 | "react@^17.0.1", "react@^17.0.2", "react@17.0.2": 85 | "integrity" "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==" 86 | "resolved" "https://registry.npmjs.org/react/-/react-17.0.2.tgz" 87 | "version" "17.0.2" 88 | dependencies: 89 | "loose-envify" "^1.1.0" 90 | "object-assign" "^4.1.1" 91 | 92 | "scheduler@^0.20.2": 93 | "integrity" "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==" 94 | "resolved" "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz" 95 | "version" "0.20.2" 96 | dependencies: 97 | "loose-envify" "^1.1.0" 98 | "object-assign" "^4.1.1" 99 | 100 | "strict-event-emitter-types@^2.0.0": 101 | "integrity" "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==" 102 | "resolved" "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz" 103 | "version" "2.0.0" 104 | -------------------------------------------------------------------------------- /packages/renderer/LICENSE: -------------------------------------------------------------------------------- 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. 10 | -------------------------------------------------------------------------------- /packages/renderer/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/renderer 2 | 3 | This package handles utilities for [Liqvid](https://liqvidjs.org) interfacing with FFmpeg and/or Puppeteer. It is used internally by [@liqvid/cli](../cli), and handles the following commands: 4 | 5 | [`liqvid audio convert`](https://liqvidjs.org/docs/cli/audio#convert) 6 | 7 | [`liqvid audio join`](https://liqvidjs.org/docs/cli/audio#join) 8 | 9 | [`liqvid render`](https://liqvidjs.org/docs/cli/render) 10 | 11 | [`liqvid thumbs`](https://liqvidjs.org/docs/cli/thumbs) 12 | -------------------------------------------------------------------------------- /packages/renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/renderer", 3 | "version": "1.0.2", 4 | "description": "Audio utilities, static video rendering, and thumbnail generation for Liqvid", 5 | "files": [ 6 | "dist/*" 7 | ], 8 | "typings": "./dist/index.d.ts", 9 | "main": "dist/index.js", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/liqvidjs/liqvid.git" 13 | }, 14 | "author": "Yuri Sulyma ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/liqvidjs/liqvid/issues" 18 | }, 19 | "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/renderer#readme", 20 | "devDependencies": { 21 | "@types/cli-progress": "^3.9.1", 22 | "@types/node": "^14.14.37", 23 | "@types/puppeteer-core": "^5.4.0", 24 | "@typescript-eslint/eslint-plugin": "^4.11.1", 25 | "@typescript-eslint/parser": "^4.11.1", 26 | "eslint": "^7.16.0", 27 | "eslint-plugin-react": "^7.21.5", 28 | "eslint-plugin-react-hooks": "^4.2.0", 29 | "typescript": "^4.1.3" 30 | }, 31 | "dependencies": { 32 | "@liqvid/utils": "^1.0.0", 33 | "cli-progress": "^3.9.0", 34 | "execa": "^5.0.0", 35 | "jimp": "^0.16.1", 36 | "puppeteer-core": "10.0.0", 37 | "puppeteer-mass-screenshots": "^1.0.15", 38 | "puppeteer-video-recorder": "^1.0.5", 39 | "yargs-parser": "^20.2.7" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/renderer/src/index.ts: -------------------------------------------------------------------------------- 1 | export {convert} from "./tasks/convert"; 2 | export {join} from "./tasks/join"; 3 | export {solidify} from "./tasks/solidify"; 4 | export {thumbs} from "./tasks/thumbs"; 5 | -------------------------------------------------------------------------------- /packages/renderer/src/tasks/convert.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | import fs, {promises as fsp} from "fs"; 3 | import path from "path"; 4 | import cliProgress from "cli-progress"; 5 | import {ffmpegExists} from "../utils/binaries"; 6 | import {formatTime, parseTime} from "@liqvid/utils/time"; 7 | 8 | /** Repair and convert audio files */ 9 | export async function convert({filename}: { 10 | filename?: string; 11 | }) { 12 | // check that ffmpeg exists 13 | if (!(await ffmpegExists())) { 14 | console.error("ffmpeg must be installed and in your PATH. Download it from"); 15 | console.error("https://ffmpeg.org/download.html"); 16 | process.exit(1); 17 | } 18 | 19 | // check that audio file exists 20 | if (!fs.existsSync(filename)) { 21 | console.error(`Audio file ${filename} not found`); 22 | process.exit(1); 23 | } 24 | 25 | /* actual conversion */ 26 | const basename = path.basename(filename, ".webm"); 27 | const dirname = path.dirname(filename); 28 | 29 | // fix browser recording 30 | console.log("(1/2) Fixing webm..."); 31 | await fixWebm(filename, path.join(dirname, basename + "-fixed.webm")); 32 | 33 | // make available in mp4 34 | console.log("(2/2) Converting to mp4..."); 35 | await convertMp4(filename, path.join(dirname, basename + ".mp4")); 36 | 37 | console.log("Done!"); 38 | } 39 | 40 | /** Reencode webm */ 41 | async function fixWebm(src: string, tmp: string) { 42 | const duration = await getDuration(src); 43 | 44 | // progress bar 45 | const bar = new cliProgress.SingleBar({ 46 | autopadding: true, 47 | clearOnComplete: true, 48 | etaBuffer: 50, 49 | format: "{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}", 50 | formatValue: (v, options, type) => { 51 | if (type === "value" || type === "total") { 52 | return formatTime(v); 53 | } 54 | return cliProgress.Format.ValueFormat(v, options, type); 55 | }, 56 | hideCursor: true 57 | }, cliProgress.Presets.shades_classic); 58 | 59 | 60 | /* ffmpeg job */ 61 | const job = execa("ffmpeg", ["-y", "-i", src, "-strict", "-2", tmp]); 62 | 63 | // parse ffmpeg progress 64 | job.stderr.on("data", (msg: Buffer) => { 65 | const $_ = msg.toString().match(/time=(\d+:\d+:\d+.\d+)/); 66 | if ($_) { 67 | bar.update(parseTime($_[1])); 68 | } 69 | }); 70 | 71 | bar.start(duration, 0); 72 | await job; 73 | bar.stop(); 74 | 75 | // rename file 76 | await fsp.rename(tmp, src); 77 | } 78 | 79 | /** Make available as mp4 */ 80 | async function convertMp4(src: string, dest: string) { 81 | const duration = await getDuration(src); 82 | 83 | // progress bar 84 | const bar = new cliProgress.SingleBar({ 85 | autopadding: true, 86 | clearOnComplete: true, 87 | etaBuffer: 50, 88 | format: "{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}", 89 | formatValue: (v, options, type) => { 90 | if (type === "value" || type === "total") { 91 | return formatTime(v); 92 | } 93 | return cliProgress.Format.ValueFormat(v, options, type); 94 | }, 95 | hideCursor: true 96 | }, cliProgress.Presets.shades_classic); 97 | 98 | /* ffmpeg job */ 99 | const job = execa("ffmpeg", ["-y", "-i", src, dest]); 100 | 101 | // parse ffmpeg progress 102 | job.stderr.on("data", (msg: Buffer) => { 103 | const $_ = msg.toString().match(/time=(\d+:\d+:\d+.\d+)/); 104 | if ($_) { 105 | bar.update(parseTime($_[1])); 106 | } 107 | }); 108 | 109 | bar.start(duration, 0); 110 | await job; 111 | bar.stop(); 112 | } 113 | 114 | /** 115 | * Get duration in milliseconds of audio file 116 | * @param filename Path to audio file 117 | * @returns Duration in milliseconds 118 | */ 119 | async function getDuration(filename: string) { 120 | const res = await execa("ffprobe", ["-i", filename, "-show_entries", "format=duration", "-v", "quiet", "-of", "csv=p=0"]); 121 | return parseFloat(res.stdout) * 1000; 122 | } 123 | -------------------------------------------------------------------------------- /packages/renderer/src/tasks/join.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | import {promises as fsp} from "fs"; 3 | import os from "os"; 4 | import path from "path"; 5 | 6 | /** 7 | * Join multiple audio files into one. 8 | */ 9 | export async function join({ 10 | filenames, 11 | output 12 | }: { 13 | /** Files to join. */ 14 | filenames: string[]; 15 | 16 | /** Destination file. If not specified, defaults to last file in filenames. */ 17 | output?: string; 18 | }) { 19 | if (filenames.length === 0) { 20 | console.error("Must provide at least one input file"); 21 | process.exit(); 22 | } 23 | 24 | if (!output) { 25 | if (filenames.length === 1) { 26 | console.error("Must provide at least one input file"); 27 | process.exit(); 28 | } 29 | output = filenames.pop(); 30 | } 31 | 32 | // create join list 33 | const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "liqvid.audio.join")); 34 | const myList = path.join(tempDir, "mylist.txt"); 35 | await fsp.writeFile(myList, filenames.map(name => `file '${name}'`).join("\n")); 36 | 37 | // ffmpeg command 38 | const ext = path.extname(output); 39 | 40 | const opts = [ 41 | "-f", "concat", 42 | "-safe", "0", 43 | "-i", myList, 44 | "-c", "copy", 45 | // special args for webm 46 | ...(ext === ".webm" ? ["-strict", "-2"] : []), 47 | output 48 | ]; 49 | 50 | const job = execa("ffmpeg", opts); 51 | await job; 52 | 53 | // clean up 54 | await fsp.rm(tempDir, {recursive: true}); 55 | } 56 | -------------------------------------------------------------------------------- /packages/renderer/src/tasks/solidify.ts: -------------------------------------------------------------------------------- 1 | import cliProgress from "cli-progress"; 2 | import fs, {promises as fsp} from "fs"; 3 | import os from "os"; 4 | import path from "path"; 5 | 6 | import {ffmpegExists, getEnsureChrome} from "../utils/binaries"; 7 | import {captureRange} from "../utils/capture"; 8 | import {validateConcurrency} from "../utils/concurrency"; 9 | import {getPages} from "../utils/connect"; 10 | import {Pool} from "../utils/pool"; 11 | import {stitch} from "../utils/stitch"; 12 | import {formatTime, parseTime} from "@liqvid/utils/time"; 13 | 14 | import {ImageFormat} from "../types"; 15 | 16 | /** 17 | Render an interactive ("liquid") video as a static ("solid") video. 18 | */ 19 | export async function solidify({ 20 | browserExecutable, 21 | colorScheme = "light", 22 | concurrency, 23 | duration, 24 | end, 25 | height, 26 | quality, 27 | sequence, 28 | url, 29 | width, 30 | ...o // passthrough parameters 31 | }: Parameters[0] & { 32 | browserExecutable: string; 33 | colorScheme: "light" | "dark"; 34 | concurrency: number; 35 | duration: number; 36 | end: number; 37 | height: number; 38 | quality: number; 39 | sequence: boolean; 40 | url: string; 41 | width: number; 42 | }) { 43 | let step = 1; 44 | const total = sequence ? 2 : 3; 45 | 46 | /* validation */ 47 | // make sure chrome exists, or download it 48 | const executablePath = await getEnsureChrome(browserExecutable); 49 | 50 | // check that ffmpeg exists 51 | if (!sequence && !(await ffmpegExists())) { 52 | console.error("ffmpeg must be installed and in your PATH. Download it from"); 53 | console.error("https://ffmpeg.org/download.html"); 54 | process.exit(1); 55 | } 56 | 57 | // check that audio file exists 58 | if (o.audioFile && !fs.existsSync(o.audioFile)) { 59 | console.error(`Audio file ${o.audioFile} not found`); 60 | process.exit(1); 61 | } 62 | 63 | // validate start/end time 64 | if (end <= o.start) { 65 | console.error("End time cannot be before start time"); 66 | process.exit(1); 67 | } 68 | 69 | // bound concurrency 70 | concurrency = validateConcurrency(concurrency); 71 | 72 | // make sure output directory exists 73 | if (sequence) { 74 | await fsp.mkdir(o.output, {recursive: true}); 75 | } 76 | 77 | /* calculate other values */ 78 | // pool of puppeteer instances 79 | console.log(`(${step++}/${total}) Connecting to players...`); 80 | const pages = await getPages({ 81 | colorScheme, concurrency, executablePath, url, height, width, 82 | }); 83 | for (const page of pages) { 84 | (page as any).client = await page.target().createCDPSession(); 85 | } 86 | const pool = new Pool(pages); 87 | 88 | // get duration 89 | const totalDuration = await pages[0].evaluate(() => { 90 | return player.playback.duration; 91 | }); 92 | 93 | if (o.start >= totalDuration) { 94 | console.error("Start cannot be after video endtime"); 95 | process.exit(1); 96 | } 97 | 98 | const realDuration = (() => { 99 | if (typeof duration === "number") { 100 | return Math.min(totalDuration - o.start, duration); 101 | } else if (typeof end === "number") { 102 | return Math.min(end - o.start, totalDuration); 103 | } 104 | return totalDuration - o.start; 105 | })(); 106 | 107 | // frames dir 108 | const framesDir = 109 | sequence ? 110 | o.output : 111 | await fsp.mkdtemp(path.join(os.tmpdir(), "liqvid.render")); 112 | 113 | // calculate how many frames 114 | const count = Math.ceil(o.fps * realDuration / 1000); 115 | const padLen = String(count - 1).length; 116 | 117 | /* capture and assemble */ 118 | // capture frames 119 | console.log(`(${step++}/${total}) Capturing frames...`); 120 | await captureRange({ 121 | count, 122 | filename: i => path.join( 123 | framesDir, 124 | String(i).padStart(padLen, "0") + `.${o.imageFormat}` 125 | ), 126 | imageFormat: o.imageFormat, 127 | pool, 128 | quality, 129 | time: i => o.start + i * 1000 / o.fps 130 | }); 131 | 132 | // close chrome instances 133 | for (const page of pages) { 134 | page.close(); 135 | } 136 | 137 | // stitch them 138 | if (!sequence) { 139 | console.log(`(${step++}/${total}) Assembling video...`); 140 | await assembleVideo({ 141 | duration: realDuration, 142 | framesDir, 143 | padLen, 144 | ...o 145 | }); 146 | 147 | // clean up tmp files 148 | console.log("Cleaning up..."); 149 | await fsp.rm(framesDir, {recursive: true}); 150 | } 151 | 152 | // done 153 | console.log("Done!"); 154 | } 155 | 156 | /** 157 | Assemble frames into a video. 158 | */ 159 | async function assembleVideo({ 160 | padLen, 161 | ...o // passthrough parameters 162 | }: Parameters[0] & { 163 | imageFormat: ImageFormat; 164 | padLen: number; 165 | }) { 166 | // progress bar 167 | const stitchingBar = new cliProgress.SingleBar({ 168 | autopadding: true, 169 | clearOnComplete: true, 170 | etaBuffer: 50, 171 | format: "{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}", 172 | formatValue: (v, options, type) => { 173 | if (type === "value" || type === "total") { 174 | return formatTime(v); 175 | } 176 | return cliProgress.Format.ValueFormat(v, options, type); 177 | }, 178 | hideCursor: true 179 | }, cliProgress.Presets.shades_classic); 180 | 181 | stitchingBar.start(o.duration, 0); 182 | 183 | // ffmpeg stitch job 184 | const job = stitch({ 185 | pattern: `%0${padLen}d.${o.imageFormat}`, 186 | ...o 187 | }); 188 | 189 | // parse ffmpeg progress 190 | job.stderr.on("data", (msg: Buffer) => { 191 | const $_ = msg.toString().match(/time=(\d+:\d+:\d+.\d+)/); 192 | if ($_) { 193 | stitchingBar.update(parseTime($_[1])); 194 | } 195 | }); 196 | 197 | await job; 198 | 199 | stitchingBar.stop(); 200 | } 201 | -------------------------------------------------------------------------------- /packages/renderer/src/tasks/thumbs.ts: -------------------------------------------------------------------------------- 1 | import cliProgress from "cli-progress"; 2 | import {promises as fsp} from "fs"; 3 | import jimp from "jimp"; 4 | import os from "os"; 5 | import path from "path"; 6 | import puppeteer from "puppeteer-core"; 7 | 8 | import {ImageFormat} from "../types"; 9 | 10 | import {getEnsureChrome} from "../utils/binaries"; 11 | import {captureRange} from "../utils/capture"; 12 | import {validateConcurrency} from "../utils/concurrency"; 13 | import {getPages} from "../utils/connect"; 14 | import {Pool} from "../utils/pool"; 15 | 16 | /** 17 | Create thumbnail sheets for a Liqvid video. 18 | */ 19 | export async function thumbs({ 20 | browserExecutable, 21 | browserHeight, 22 | browserWidth, 23 | colorScheme = "light", 24 | cols, 25 | concurrency, 26 | frequency, 27 | height, 28 | imageFormat, 29 | output, 30 | quality, 31 | rows, 32 | url, 33 | width 34 | }: { 35 | browserExecutable: string; 36 | browserHeight: number; 37 | browserWidth: number; 38 | colorScheme: "light" | "dark"; 39 | cols: number; 40 | concurrency: number; 41 | frequency: number; 42 | height: number; 43 | imageFormat: ImageFormat; 44 | output: string; 45 | quality: number; 46 | rows: number; 47 | url: string; 48 | width: number; 49 | }) { 50 | let step = 1; 51 | const total = 3; 52 | 53 | // validation 54 | const executablePath = await getEnsureChrome(browserExecutable); 55 | 56 | if (path.extname(output) !== `.${imageFormat}`) { 57 | console.error(`Error: File pattern '${output}' does not match format '${imageFormat}'.`); 58 | process.exit(1); 59 | } 60 | 61 | concurrency = validateConcurrency(concurrency); 62 | 63 | // browserHeight / browserWidth default to height/width 64 | browserHeight ??= height; 65 | browserWidth ??= width; 66 | 67 | // make directories 68 | const [tmpDir] = await Promise.all([ 69 | fsp.mkdtemp(path.join(os.tmpdir(), "liqvid.thumbs")), 70 | fsp.mkdir(path.dirname(output), {recursive: true}) 71 | ]); 72 | 73 | // pool of puppeteer instances 74 | console.log(`(${step++}/${total}) Connecting to players...`); 75 | const pages = await getPages({ 76 | colorScheme, 77 | concurrency, url, 78 | executablePath, 79 | height: browserHeight, width: browserWidth 80 | }); 81 | const pool = new Pool(pages); 82 | for (const page of pages) { 83 | (page as any).client = await page.target().createCDPSession(); 84 | } 85 | 86 | // calculate how many thumbs 87 | const duration = await pages[0].evaluate(() => { 88 | return player.playback.duration; 89 | }); 90 | 91 | const numThumbs = Math.ceil(duration / frequency / 1000); 92 | 93 | // grab thumbs and assemble them 94 | console.log(`(${step++}/${total}) Capturing thumbs...`); 95 | await captureRange({ 96 | count: numThumbs, 97 | filename: i => path.join(tmpDir, `${i}.${imageFormat}`), 98 | imageFormat, 99 | pool, 100 | time: i => i * frequency * 1000 101 | }); 102 | 103 | // close chrome instances 104 | pages[0].browser().close(); 105 | 106 | console.log(`(${step++}/${total}) Assembling sheets...`); 107 | await assembleSheets({cols, height, imageFormat, numThumbs, output, pool, quality, rows, tmpDir, width}); 108 | 109 | // clean up tmp files 110 | console.log("Cleaning up..."); 111 | await fsp.rm(tmpDir, {recursive: true}); 112 | 113 | // done 114 | console.log("Done!"); 115 | } 116 | 117 | /** 118 | Assemble thumb screenshots into sheets. 119 | */ 120 | async function assembleSheets({ 121 | cols, 122 | height, 123 | imageFormat, 124 | numThumbs, 125 | output, 126 | pool, 127 | quality, 128 | rows, 129 | tmpDir, 130 | width 131 | }: { 132 | cols: number; 133 | height: number; 134 | imageFormat: ImageFormat; 135 | numThumbs: number; 136 | output: string; 137 | pool: Pool; 138 | quality: number; 139 | rows: number; 140 | tmpDir: string; 141 | width: number; 142 | }) { 143 | const numSheets = Math.ceil(numThumbs / cols / rows); 144 | 145 | // progress bar 146 | const sheetsBar = new cliProgress.SingleBar({ 147 | autopadding: true, 148 | clearOnComplete: true, 149 | format: "{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}", 150 | hideCursor: true 151 | }, cliProgress.Presets.shades_classic); 152 | 153 | sheetsBar.start(numThumbs, 0); 154 | 155 | await Promise.all( 156 | new Array(numSheets) 157 | .fill(null) 158 | .map(async (_, sheetNum) => { 159 | 160 | // get available puppeteer instance 161 | const page = await pool.acquire(); 162 | 163 | const sheet = await new jimp(cols * width, rows * height); 164 | 165 | // blit thumbs into here 166 | await Promise.all( 167 | new Array(cols * rows) 168 | .fill(null) 169 | .map(async (_, i) => { 170 | const index = sheetNum * cols * rows + i; 171 | if (index >= numThumbs) return; 172 | 173 | const thumb = await jimp.read(path.join(tmpDir, `${index}.${imageFormat}`)); 174 | if (imageFormat === "jpeg") { 175 | thumb.quality(quality); 176 | } 177 | await thumb.resize(width, height); 178 | await sheet.blit(thumb, (i % cols) * width, Math.floor(i / rows) * height); 179 | sheetsBar.increment(); 180 | }) 181 | ); 182 | 183 | await sheet.writeAsync(output.replace("%s", sheetNum.toString())); 184 | 185 | // release puppeteer instance 186 | pool.release(page); 187 | }) 188 | ); 189 | sheetsBar.stop(); 190 | } 191 | -------------------------------------------------------------------------------- /packages/renderer/src/types.ts: -------------------------------------------------------------------------------- 1 | export type ImageFormat = "jpeg" | "png"; 2 | 3 | // hilarious!! 4 | declare global { 5 | const Liqvid: { 6 | Utils: { 7 | misc: { 8 | waitFor(callback: () => boolean, interval?: number): Promise; 9 | } 10 | } 11 | } 12 | var player: { 13 | canPlay: Promise; 14 | playback: { 15 | duration: number; 16 | play(): Promise; 17 | seek: (t: number) => void; 18 | } 19 | }; 20 | } 21 | 22 | // declare module "puppeteer-core" { 23 | // export interface Page { 24 | // screenshot(): Promise; 25 | // } 26 | // } 27 | -------------------------------------------------------------------------------- /packages/renderer/src/utils/binaries.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | import fs from "fs"; 3 | import os from "os"; 4 | // sillyness 5 | import Puppeteer from "puppeteer-core"; 6 | 7 | const puppeteer = Puppeteer as unknown as Puppeteer.PuppeteerNode; 8 | 9 | export async function ffmpegExists() { 10 | const locate = os.platform() === "win32" ? "where" : "which"; 11 | try { 12 | await execa(locate, ["ffmpeg"]); 13 | return true; 14 | } catch (e) { 15 | return false; 16 | } 17 | } 18 | 19 | /** 20 | Ensure that a Chrome/ium executable exists on the machine, and return the path to it. 21 | */ 22 | export async function getEnsureChrome(userChrome: string) { 23 | // user-supplied path 24 | if (userChrome) { 25 | if (!fs.existsSync(userChrome)) { 26 | console.warn(`Could not find browser executable at ${userChrome}`); 27 | } else { 28 | return userChrome; 29 | } 30 | } 31 | 32 | // typical install 33 | const systemChrome = await findChromeByPlatform(); 34 | if (systemChrome) 35 | return systemChrome; 36 | 37 | // puppeteer preinstalled 38 | const preinstalledChrome = puppeteer.executablePath(); 39 | if (fs.existsSync(preinstalledChrome)) 40 | return preinstalledChrome; 41 | 42 | // puppeteer install 43 | console.log("No Chrome installation found. Downloading one from the internet..."); 44 | const browserFetcher = puppeteer.createBrowserFetcher({}); 45 | const revisionInfo = await browserFetcher.download(puppeteer._preferredRevision); 46 | return revisionInfo.executablePath; 47 | } 48 | 49 | /** 50 | Look for Chrome/ium in standard locations across platforms. 51 | */ 52 | async function findChromeByPlatform() { 53 | switch (process.platform) { 54 | case "win32": 55 | return [ 56 | "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", 57 | "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe" 58 | ].find(location => fs.existsSync(location)); 59 | case "darwin": 60 | return [ 61 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 62 | ].find(location => fs.existsSync(location)); 63 | default: 64 | try { 65 | const {stdout} = await execa("which", ["google-chrome", "chromium", "chromium-browser"]); 66 | return stdout.split("\n")[0]; 67 | } catch (e) { 68 | const {stdout} = e; 69 | return stdout.split("\n").filter(Boolean)[0]; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/renderer/src/utils/capture.ts: -------------------------------------------------------------------------------- 1 | import cliProgress from "cli-progress"; 2 | import type puppeteer from "puppeteer-core"; 3 | 4 | import type {Pool} from "./pool"; 5 | import {ImageFormat} from "../types"; 6 | 7 | import {promises as fsp} from "fs"; 8 | 9 | export async function capture({ 10 | page, 11 | path, 12 | quality, 13 | time, 14 | type 15 | }: { 16 | page: puppeteer.Page; 17 | path: string; 18 | quality?: number | undefined; 19 | time: number; 20 | type: ImageFormat; 21 | }) { 22 | await page.evaluate((time) => { 23 | player.playback.seek(time); 24 | }, time); 25 | 26 | const client = (page as any).client as puppeteer.CDPSession; 27 | const options = { 28 | format: type, 29 | quality: type === "jpeg" ? quality : undefined 30 | }; 31 | 32 | const {data} = await client.send('Page.captureScreenshot', options); 33 | const base64Data = data.replace(/^data:image\/png;base64,/, ""); 34 | 35 | return fsp.writeFile(path, base64Data, 'base64'); 36 | 37 | return page.screenshot({ 38 | omitBackground: type === "png", 39 | path, 40 | // puppeteer will throw error if quality is passed for png 41 | quality: type === "jpeg" ? quality : undefined, 42 | type 43 | }); 44 | } 45 | 46 | /** 47 | Capture a range of frames. 48 | */ 49 | export async function captureRange({ 50 | count, 51 | filename, 52 | imageFormat, 53 | pool, 54 | quality, 55 | time 56 | }: { 57 | count: number; 58 | filename: (i: number) => string; 59 | imageFormat: ImageFormat; 60 | pool: Pool; 61 | quality?: number | undefined; 62 | time: (i: number) => number; 63 | }) { 64 | // progress bar 65 | const captureBar = new cliProgress.SingleBar({ 66 | autopadding: true, 67 | clearOnComplete: true, 68 | format: "{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}", 69 | hideCursor: true 70 | }, cliProgress.Presets.shades_classic); 71 | captureBar.start(count, 0); 72 | 73 | // grab the thumbs 74 | await Promise.all( 75 | new Array(count) 76 | .fill(null) 77 | .map(async (_, i) => { 78 | // get available puppeteer instance 79 | const page = await pool.acquire(); 80 | 81 | // capture frame 82 | await capture({ 83 | page, 84 | time: time(i), 85 | type: imageFormat, 86 | path: filename(i), 87 | quality 88 | }); 89 | captureBar.increment(); 90 | 91 | // release puppeteer instance 92 | pool.release(page); 93 | }) 94 | ); 95 | 96 | captureBar.stop(); 97 | } 98 | -------------------------------------------------------------------------------- /packages/renderer/src/utils/concurrency.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | 3 | /** 4 | Restrict concurrency to allowable values and avoid warnings. 5 | */ 6 | export function validateConcurrency(concurrency: number) { 7 | // constrain 8 | concurrency = Math.max(1, Math.min(os.cpus().length, concurrency)); 9 | 10 | // force integer values 11 | concurrency = Math.floor(concurrency); 12 | 13 | // avoid EventEmitter warnings 14 | if (concurrency > 10) { 15 | process.setMaxListeners(0); 16 | } 17 | 18 | return concurrency; 19 | } -------------------------------------------------------------------------------- /packages/renderer/src/utils/connect.ts: -------------------------------------------------------------------------------- 1 | import cliProgress from "cli-progress"; 2 | import puppeteer from "puppeteer-core"; 3 | 4 | /** 5 | Connect to a page running Liqvid. 6 | */ 7 | export async function connect({ 8 | browser, 9 | colorScheme = "light", 10 | height, 11 | url, 12 | width 13 | }: { 14 | browser: puppeteer.Browser; 15 | colorScheme?: "light" | "dark"; 16 | height: number; 17 | url: string; 18 | width: number; 19 | }) { 20 | // init page 21 | const page = await browser.newPage(); 22 | page.setViewport({height, width}); 23 | page.on("error", console.error); 24 | page.on("pageerror", console.error); 25 | 26 | await page.goto(url, {timeout: 0}); 27 | 28 | await page.waitForSelector(".rp-controls, .lv-controls"); 29 | 30 | // hide controls 31 | await page.evaluate(() => { 32 | (document.querySelector(".rp-controls") as HTMLDivElement).style.display = "none"; 33 | document.body.style.background = "transparent"; 34 | }); 35 | 36 | // set color scheme 37 | await page.emulateMediaFeatures([{ 38 | name: "prefers-color-scheme", value: colorScheme 39 | }]); 40 | 41 | // set player as global variable 42 | // HA HA HA THIS IS HORRIBLE 43 | await page.evaluate(async () => { 44 | const searchKeys = ["child", "stateNode", "current"]; 45 | 46 | function searchTree(obj: any, depth=0): unknown { 47 | if (depth > 5) 48 | return; 49 | for (const key of searchKeys) { 50 | if (!obj[key]) 51 | continue; 52 | 53 | if ("playback" in obj[key]) { 54 | return obj[key]; 55 | } 56 | else if (typeof obj[key] === "object") { 57 | const result = searchTree(obj[key], depth+1); 58 | if (result) 59 | return result; 60 | } 61 | } 62 | } 63 | 64 | const root = document.querySelector(".ractive-player").parentNode; 65 | const key = Object.keys(root).find(key => key.startsWith("__reactContainer")); 66 | 67 | await Liqvid.Utils.misc.waitFor( 68 | () => (window as any).player = searchTree(root[key as keyof typeof root]) as boolean 69 | ); 70 | }); 71 | 72 | return page; 73 | } 74 | 75 | /** 76 | Connect to players. 77 | */ 78 | export async function getPages({ 79 | colorScheme = "light", 80 | concurrency, 81 | executablePath, 82 | height, 83 | url, 84 | width 85 | }: { 86 | colorScheme: "light" | "dark"; 87 | concurrency: number; 88 | executablePath: string; 89 | height: number; 90 | url: string; 91 | width: number; 92 | }) { 93 | // progress bar 94 | const playerBar = new cliProgress.SingleBar({ 95 | autopadding: true, 96 | clearOnComplete: true, 97 | etaBuffer: 1, 98 | format: "{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}", 99 | hideCursor: true 100 | }, cliProgress.Presets.shades_classic); 101 | playerBar.start(concurrency, 0); 102 | 103 | // get local browser 104 | const browser = await puppeteer.launch({ 105 | args: [ 106 | process.platform === "linux" ? "--single-process" : null 107 | ].filter(Boolean), 108 | executablePath, 109 | ignoreHTTPSErrors: true, 110 | product: "chrome", 111 | timeout: 0 112 | }); 113 | 114 | // array of Page objects 115 | const pages = await Promise.all( 116 | new Array(concurrency) 117 | .fill(null) 118 | .map(async () => { 119 | const page = await connect({ 120 | browser, 121 | colorScheme, 122 | height, 123 | width, 124 | url 125 | }); 126 | 127 | playerBar.increment(); 128 | 129 | return page; 130 | }) 131 | ); 132 | playerBar.stop(); 133 | 134 | return pages; 135 | } 136 | -------------------------------------------------------------------------------- /packages/renderer/src/utils/pool.ts: -------------------------------------------------------------------------------- 1 | export class Pool { 2 | private instances: T[]; 3 | private queue: ((free: T) => void)[]; 4 | 5 | constructor(instances: T[]) { 6 | this.instances = instances; 7 | this.queue = []; 8 | } 9 | 10 | acquire() { 11 | const instance = this.instances.shift(); 12 | if (undefined !== instance) { 13 | return Promise.resolve(instance); 14 | } 15 | return new Promise((resolve) => { 16 | this.queue.push((free: T) => resolve(free)); 17 | }); 18 | } 19 | 20 | release(instance: T) { 21 | const next = this.queue.shift(); 22 | if (undefined === next) { 23 | this.instances.push(instance); 24 | } else { 25 | next(instance); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/renderer/src/utils/stitch.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | import path from "path"; 3 | import parser from "yargs-parser"; 4 | 5 | import {formatTimeMs} from "@liqvid/utils/time"; 6 | 7 | /** 8 | Stitch frames together into a video. 9 | */ 10 | export function stitch({ 11 | audioArgs, 12 | audioFile, 13 | duration, 14 | fps, 15 | framesDir, 16 | pattern, 17 | output, 18 | pixelFormat, 19 | start, 20 | videoArgs, 21 | }: { 22 | audioArgs: string; 23 | audioFile: string | undefined; 24 | duration: number; 25 | fps: number; 26 | framesDir: string; 27 | pattern: string; 28 | output: string; 29 | pixelFormat: string; 30 | start?: number; 31 | videoArgs: string; 32 | }) { 33 | /* images */ 34 | const args = [ 35 | // framerate 36 | "-framerate", 37 | String(fps), 38 | 39 | // frames 40 | "-i", 41 | path.join(framesDir, pattern) 42 | ]; 43 | 44 | /* audio */ 45 | if (audioFile) { 46 | args.push( 47 | // start time 48 | "-ss", 49 | formatTimeMs(start), 50 | 51 | // duration 52 | "-t", 53 | formatTimeMs(duration), 54 | 55 | // audio args 56 | ...splitArgs(audioArgs), 57 | 58 | // audio file 59 | "-i", 60 | audioFile 61 | ); 62 | } 63 | 64 | /* video */ 65 | args.push( 66 | // pixel format 67 | "-pix_fmt", 68 | pixelFormat, 69 | 70 | // force overwrite 71 | "-y", 72 | 73 | // video args 74 | ...splitArgs(videoArgs), 75 | 76 | output 77 | ); 78 | return execa("ffmpeg", args.filter(Boolean)); 79 | } 80 | 81 | // fuck 82 | function splitArgs(combined: string) { 83 | if (!combined) 84 | return []; 85 | 86 | const parsed = parser(combined, { 87 | configuration: { 88 | "short-option-groups": false 89 | } 90 | }); 91 | return ( 92 | Object.keys(parsed) 93 | .reduce((opts, key) => { 94 | if(key === "_") 95 | return opts; 96 | if (typeof parsed[key] === "boolean") 97 | return opts.concat([`-${key}`]); 98 | return opts.concat([`-${key}`, parsed[key]]); 99 | }, []) 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /packages/renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": ".", 7 | "module": "commonjs", 8 | "target": "esnext" 9 | }, 10 | "include": ["./src"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/LICENSE: -------------------------------------------------------------------------------- 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. 10 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # @liqvid/server 2 | 3 | This package provides a development server for [Liqvid](https://liqvidjs.org). It is used internally by [@liqvid/cli](../cli). 4 | 5 | ```bash 6 | liqvid serve 7 | ``` 8 | 9 | See https://liqvidjs.org/docs/cli/serve for full documentation. 10 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liqvid/server", 3 | "version": "1.0.1", 4 | "description": "Development server for Liqvid", 5 | "main": "./dist/index.js", 6 | "typings": "./dist/index.d.ts", 7 | "files": [ 8 | "dist/*" 9 | ], 10 | "author": "Yuri Sulyma ", 11 | "license": "MIT", 12 | "private": false, 13 | "dependencies": { 14 | "@liqvid/magic": "^1.0.0", 15 | "@types/express": "^4.17.13", 16 | "@types/node": "^16.11.13", 17 | "body-parser": "^1.19.1", 18 | "compression": "^1.7.4", 19 | "cookie-parser": "^1.4.6", 20 | "express": "^4.17.1", 21 | "livereload": "^0.9.3", 22 | "typescript": "^4.5.4", 23 | "webpack": "^5.65.0" 24 | }, 25 | "devDependencies": { 26 | "@types/compression": "^1.7.2", 27 | "@types/cookie-parser": "^1.4.2", 28 | "@types/livereload": "^0.9.1", 29 | "eslint": "^8.4.1", 30 | "eslint-plugin-react": "^7.27.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import {ScriptData, scripts as defaultScripts, StyleData, styles as defaultStyles, transform} from "@liqvid/magic"; 2 | import bodyParser from "body-parser"; 3 | import {exec} from "child_process"; 4 | import compression from "compression"; 5 | import cookieParser from "cookie-parser"; 6 | import express from "express"; 7 | import {promises as fsp} from "fs"; 8 | import livereload from "livereload"; 9 | import * as path from "path"; 10 | import webpack from "webpack"; 11 | import type {AddressInfo} from "ws"; 12 | 13 | /** 14 | * Create Express app to run Liqvid development server. 15 | */ 16 | export function createServer(config: { 17 | /** 18 | * Build directory. 19 | */ 20 | build?: string; 21 | 22 | /** 23 | * Port to run LiveReload on. 24 | */ 25 | livereloadPort?: number; 26 | 27 | /** 28 | * Port to run the server on. 29 | */ 30 | port?: number; 31 | 32 | /** 33 | * Static directory. 34 | */ 35 | static?: string; 36 | 37 | scripts?: Record; 38 | 39 | styles?: Record; 40 | }) { 41 | const app = express(); 42 | 43 | // standard stuff 44 | app.use(compression()); 45 | 46 | /* body parsing? */ 47 | app.use(cookieParser(/*process.env.SECURE_KEY*/)); 48 | app.use(bodyParser.json({limit: "50mb"})); 49 | app.use(bodyParser.urlencoded({ 50 | extended: true 51 | })); 52 | 53 | // vars 54 | app.set("static", config.static); 55 | 56 | // livereload 57 | const lr = createLivereload(config.livereloadPort, config.static); 58 | const lrPort = (lr.server.address() as AddressInfo).port; 59 | app.set("livereloadPort", lrPort); 60 | 61 | // magic 62 | const scripts = Object.assign({}, defaultScripts, config.scripts ?? {}, { 63 | "livereload": { 64 | development() { 65 | return ( 66 | "document.write(`