Repository: liqvidjs/player Branch: main Commit: 771ca4eba36e Files: 258 Total size: 404.6 KB Directory structure: gitextract_isymmvu1/ ├── .gitignore ├── LICENSE ├── README.md ├── biome.json ├── build.mjs ├── package.json ├── packages/ │ ├── captioning/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── transcription.ts │ │ │ └── webvtt.ts │ │ └── tsconfig.json │ ├── cli/ │ │ ├── liqvid-cli.mjs │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.mts │ │ │ └── tasks/ │ │ │ ├── audio.mts │ │ │ ├── build.mts │ │ │ ├── config.mts │ │ │ ├── index.mts │ │ │ ├── load-sync.cts │ │ │ ├── render.mts │ │ │ ├── serve.mts │ │ │ └── thumbs.mts │ │ └── tsconfig.json │ ├── diff/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── apply.ts │ │ │ ├── builders.ts │ │ │ ├── compute.ts │ │ │ ├── index.ts │ │ │ ├── merge.ts │ │ │ ├── runes.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tests/ │ │ │ └── suite.test.ts │ │ └── tsconfig.json │ ├── duration/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── gsap/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── host/ │ │ ├── README.md │ │ ├── lv-host.js │ │ └── package.json │ ├── hydration/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── HydrateElement.tsx │ │ │ ├── HydrateOnClient.tsx │ │ │ ├── HydrateVariants.tsx │ │ │ ├── SneakyScript.tsx │ │ │ ├── golf.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── katex/ │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── RenderGroup.ts │ │ │ ├── fancy.tsx │ │ │ ├── index.tsx │ │ │ ├── loading.ts │ │ │ └── plain.tsx │ │ └── tsconfig.json │ ├── keymap/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── mixedCaseVals.ts │ │ │ └── react.ts │ │ ├── tests/ │ │ │ └── keymap.test.ts │ │ └── tsconfig.json │ ├── magic/ │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── default-assets.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── tests/ │ │ │ └── magic.test.ts │ │ └── tsconfig.json │ ├── main/ │ │ ├── CHANGELOG.md │ │ ├── DEVELOPMENT.md │ │ ├── README.md │ │ ├── e2e/ │ │ │ ├── app/ │ │ │ │ ├── package.json │ │ │ │ ├── src/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── static/ │ │ │ │ │ ├── index.html │ │ │ │ │ └── style.css │ │ │ │ ├── tsconfig.json │ │ │ │ └── webpack.config.js │ │ │ └── tests/ │ │ │ └── Media.spec.tsx │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── Audio.tsx │ │ │ ├── CaptionsDisplay.tsx │ │ │ ├── Controls.tsx │ │ │ ├── IdMap.tsx │ │ │ ├── Media.ts │ │ │ ├── Player.tsx │ │ │ ├── Video.tsx │ │ │ ├── controls/ │ │ │ │ ├── Captions.tsx │ │ │ │ ├── FullScreen.tsx │ │ │ │ ├── PlayPause.tsx │ │ │ │ ├── ScrubberBar.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ ├── ThumbnailBox.tsx │ │ │ │ ├── TimeDisplay.tsx │ │ │ │ └── Volume.tsx │ │ │ ├── fake-fullscreen.ts │ │ │ ├── hooks.ts │ │ │ ├── i18n.ts │ │ │ ├── index.ts │ │ │ ├── playback.ts │ │ │ ├── polyfills.ts │ │ │ ├── script.ts │ │ │ ├── utils/ │ │ │ │ ├── authoring.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── interactivity.ts │ │ │ │ ├── media.ts │ │ │ │ ├── mobile.ts │ │ │ │ └── rsc.ts │ │ │ └── utils.ts │ │ ├── styl/ │ │ │ ├── controls/ │ │ │ │ ├── captions.styl │ │ │ │ ├── scrubber.styl │ │ │ │ ├── settings.styl │ │ │ │ ├── thumbs.styl │ │ │ │ ├── time.styl │ │ │ │ └── volume.styl │ │ │ ├── liqvid.styl │ │ │ └── mobile.styl │ │ ├── tests/ │ │ │ ├── DocumentTimeline.mock │ │ │ ├── IdMap.test.tsx │ │ │ ├── Player.test.tsx │ │ │ ├── controls/ │ │ │ │ ├── PlayPause.test.tsx │ │ │ │ ├── ScrubberBar.test.tsx │ │ │ │ ├── TimeDisplay.test.tsx │ │ │ │ ├── Volume.test.tsx │ │ │ │ └── __snapshots__/ │ │ │ │ ├── PlayPause.test.tsx.snap │ │ │ │ └── Volume.test.tsx.snap │ │ │ ├── hooks.test.tsx │ │ │ ├── matchMedia.mock │ │ │ └── script.test.ts │ │ └── tsconfig.json │ ├── mathjax/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── RenderGroup.ts │ │ │ ├── fancy.tsx │ │ │ ├── index.ts │ │ │ ├── loading.ts │ │ │ └── plain.tsx │ │ ├── test/ │ │ │ └── index.js │ │ └── tsconfig.json │ ├── playback/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── animation.ts │ │ │ ├── core.ts │ │ │ ├── index.ts │ │ │ └── react.ts │ │ ├── tests/ │ │ │ └── core.test.ts │ │ └── tsconfig.json │ ├── player/ │ │ └── package.json │ ├── polyfills/ │ │ ├── package.json │ │ └── src/ │ │ ├── polyfills.ts │ │ └── waapi.js │ ├── prompt/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Cue.tsx │ │ │ ├── Prompt.tsx │ │ │ └── index.ts │ │ ├── style.css │ │ ├── style.styl │ │ └── tsconfig.json │ ├── react/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── three.tsx │ │ └── tsconfig.json │ ├── react-three/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.tsx │ │ └── tsconfig.json │ ├── recording/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Control.tsx │ │ │ ├── RecordingManager.ts │ │ │ ├── RecordingRow.tsx │ │ │ ├── index.ts │ │ │ ├── recorder.ts │ │ │ ├── recorders/ │ │ │ │ ├── audio-recording.tsx │ │ │ │ ├── marker-recording.tsx │ │ │ │ ├── replay-data-recorder.ts │ │ │ │ └── video-recording.tsx │ │ │ └── types.ts │ │ ├── styl/ │ │ │ └── style.styl │ │ └── tsconfig.json │ ├── renderer/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.mts │ │ │ ├── tasks/ │ │ │ │ ├── convert.mts │ │ │ │ ├── join.mts │ │ │ │ ├── solidify.mts │ │ │ │ └── thumbs.mts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── binaries.mts │ │ │ ├── capture.mts │ │ │ ├── concurrency.mts │ │ │ ├── connect.mts │ │ │ ├── pool.mts │ │ │ └── stitch.mts │ │ └── tsconfig.json │ ├── server/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── ssr/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── react.ts │ │ └── tsconfig.json │ ├── utils/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── animation.ts │ │ │ ├── interaction.ts │ │ │ ├── interactivity.ts │ │ │ ├── json.ts │ │ │ ├── misc.ts │ │ │ ├── react.ts │ │ │ ├── replay-data.ts │ │ │ ├── ssr.ts │ │ │ ├── svg.ts │ │ │ ├── time.ts │ │ │ └── types.ts │ │ ├── tests/ │ │ │ ├── animation.test.ts │ │ │ ├── json.test.ts │ │ │ ├── misc.test.ts │ │ │ ├── react.test.tsx │ │ │ └── time.test.ts │ │ └── tsconfig.json │ └── xyjax/ │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ ├── src/ │ │ └── index.ts │ └── tsconfig.json ├── pnpm-workspace.yaml └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ packages/**/LICENSE # Node node_modules dist coverage tsconfig.tsbuildinfo *.log # Configuration .env # Editors *.code-* *.sublime-* # Generated bundle.js # Operating system .DS_Store # static files *.mp4 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Yuri Sulyma 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================ # Liqvid [Liqvid](https://liqvidjs.org/) is a library for creating **interactive** videos in React. ## Links [Documentation](https://liqvidjs.org/docs/) [Discord](https://discord.gg/u8Qab99zHx) ## Repository structure This is a monorepo. Here is what the various packages do: ### Frontend Core * `main` Provides the main `liqvid` package. * `host` Script for pages hosting Liqvid videos; currently just handles [fake fullscreen](https://liqvidjs.org/docs/guide/mobile#fake-fullscreen) * `keymap` Provides the [`Keymap`](https://liqvidjs.org/docs/reference/Keymap) class * `playback` Provides the [`Playback`](https://liqvidjs.org/docs/reference/Playback) class * `polyfills` Polyfills for Liqvid videos; currently just handles [Web Animations](https://liqvidjs.org/docs/guide/mobile/#web-animations) * `utils` Provides the various helper functions in [`Utils`](https://liqvidjs.org/docs/reference/Utils/animation) ### Backend Tools * `cli` The Liqvid [CLI tool](https://liqvidjs.org/docs/cli/tool) * `magic` Provides wacky[resource macro](https://liqvidjs.org/docs/cli/macros) syntax * `renderer` Handles the [`audio`](https://liqvidjs.org/docs/cli/audio), [`build`](https://liqvidjs.org/docs/cli/build), [`render`](https://liqvidjs.org/docs/cli/render), and [`thumbs`](https://liqvidjs.org/docs/cli/thumbs) CLI commands * `serve` Development server; provides the [`serve`](https://liqvidjs.org/docs/cli/tool) CLI command ### Integrations * `katex` Provides [KaTeX integration](https://liqvidjs.org/docs/integrations/katex) * `react-three` Provides [React Three Fiber](https://liqvidjs.org/docs/integrations/three) integration ### In-development * `captioning` Captions editor * `gsap` [GSAP](https://greensock.com/gsap/) integration (maybe already works???) * `i18n` Internationalization utilities * `player` New Web Components-based `` * `mathjax` [MathJax](https://www.mathjax.org/) integration * `react` Probably for when Liqvid goes to Web Components (v3) * `xyjax` [XyJax](https://github.com/sonoisa/XyJax-v3/) integration ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", "assist": { "actions": { "source": { "organizeImports": { "level": "on", "options": { "groups": [ ":NODE:", ":BLANK_LINE:", ":PACKAGE:", ":BLANK_LINE:", ":ALIAS:", ":BLANK_LINE:", ["../**", "!**/*.css", "!**/*.json", "!**/*.scss"], ":BLANK_LINE:", ["./**", "!**/*.css", "!**/*.json", "!**/*.scss"], ":BLANK_LINE:", ["**/*.css", "**/*.scss"], ":BLANK_LINE:", "**/*.json" ] } }, "useSortedAttributes": "on", "useSortedKeys": "on" } } }, "css": { "parser": { "tailwindDirectives": true } }, "files": { "ignoreUnknown": false, "includes": [ "**", "!build", "!**/dist", "!**/package.json", "!**/package-lock.json", "!**/node_modules", "!public/vendor/mathjax/*", "!**/recordings/*.json", "!**/recordings.json", "!**/bundle.js" ] }, "formatter": { "attributePosition": "auto", "bracketSpacing": true, "enabled": true, "formatWithErrors": false, "indentStyle": "space", "indentWidth": 2, "lineEnding": "lf", "lineWidth": 80, "useEditorconfig": true }, "javascript": { "formatter": { "arrowParentheses": "always", "attributePosition": "auto", "bracketSameLine": false, "bracketSpacing": true, "jsxQuoteStyle": "double", "quoteProperties": "asNeeded", "quoteStyle": "double", "semicolons": "always", "trailingCommas": "all" } }, "linter": { "enabled": true, "rules": { "correctness": { "useExhaustiveDependencies": "error", "useUniqueElementIds": "off" }, "nursery": { "useSortedClasses": { "fix": "safe", "level": "info", "options": { "functions": ["clsx", "cva", "tw", "classNames"] } } }, "recommended": true, "style": { "useTemplate": "off" }, "suspicious": { "noUnknownAtRules": "off" } } }, "vcs": { "clientKind": "git", "enabled": false, "useIgnoreFile": true } } ================================================ FILE: build.mjs ================================================ /* Horrifying fixer-upper for ESM imports */ import * as fs from "fs"; import {existsSync, promises as fsp, readFileSync} from "fs"; import * as path from "path"; const DIST = path.join(process.cwd(), "dist"); const NODE_MODULES = path.join(process.cwd(), "node_modules"); build(); async function build() { for (const type of ["esm", "cjs"]) { const dir = path.join(DIST, type); const extn = type === "esm" ? "mjs" : "cjs"; // rename files first await walkDir(dir, async (filename) => { if (!filename.endsWith(".js")) return; await renameExtension(filename, extn); }); // now fix imports await walkDir(dir, async (filename) => { if (!filename.endsWith(`.${extn}`)) return; await fixImports(filename, type); }); } } /** * Recursively walk a directory * @param {string} dirname Name of directory to walk * @param {(filename: string) => Promise} callback Callback */ async function walkDir(dirname, callback) { const files = (await fsp.readdir(dirname)).map((filename) => path.join(dirname, filename), ); /* first rename all files */ await Promise.all( files.map(async (filename) => { const stat = await fsp.stat(filename); if (stat.isDirectory()) { return walkDir(filename, callback); } await callback(filename); }), ); } /** * Add extensions to relative imports * @param {string} filename File to operate on. * @param {"esm" | "cjs"} type Javascript module system in use. */ async function fixImports(filename, type = "esm") { let content = await fsp.readFile(filename, "utf8"); const regex = type === "esm" ? /^((?:ex|im)port .+? from\s+)(["'])(.+?)(\2;?)$/gm : /(require\()(['"])(.+?)(\2\))/gm; content = content.replaceAll(regex, (match, head, q, name, tail) => { // already has extension if (name.match(/\.[cm]?js$/)) { return match; } // relative imports if (name.startsWith(".")) { // figure out which file it's referring to const target = findExtension(path.dirname(filename), name); return head + q + target + tail; } else { } return match; }); await fsp.writeFile(filename, content); } /** * Find extension */ function findExtension(pathname, relative) { const filename = path.resolve(pathname, relative); for (const extn of ["mjs", "js", "cjs"]) { const full = filename + "." + extn; if (existsSync(full)) { let rewrite = path.relative(pathname, full); if (!rewrite.startsWith(".")) { rewrite = "./" + rewrite; } return rewrite; } } throw new Error(`Could not resolve ${filename}`); } /** * Find package.json * @param {string} name Name of package. */ function findPackageJson(name) { const packageName = getPackageName(name); let dirname = NODE_MODULES; while (true) { const filename = path.join(dirname, packageName, "package.json"); if (fs.existsSync(filename)) return filename; dirname = path.normalize(path.join(dirname, "..")); if (dirname === "/") throw new Error(`Could not find package.json for ${name}`); } } /** * Get name of NPM package * @param {string} name Import string to resolve. */ function getPackageName(name) { const parts = name.split("/"); if (name.startsWith("@")) { return parts.slice(0, 2).join("/"); } return parts[0]; } /** * Change file extension */ async function renameExtension(filename, extn = "mjs") { await fsp.rename(filename, filename.replace(/\.js$/, `.${extn}`)); } ================================================ FILE: package.json ================================================ { "name": "root", "private": true, "workspaces": ["packages/*"], "devDependencies": { "@babel/core": "^7.17.10", "@babel/plugin-transform-modules-umd": "^7.16.7", "@babel/preset-env": "^7.17.10", "@biomejs/biome": "1.9.4", "@playwright/experimental-ct-react": "^1.27.1", "@playwright/test": "^1.27.1", "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-commonjs": "^22.0.0", "@rollup/plugin-node-resolve": "^13.3.0", "@testing-library/dom": "^8.13.0", "@testing-library/react": "^13.2.0", "@testing-library/user-event": "^14.2.0", "@types/jest": "^27.5.1", "@types/node": "^22.10.10", "@types/react": "^18.0.9", "@types/react-dom": "^18.0.4", "concurrently": "^7.5.0", "dotenv": "^16.0.3", "jest": "^28.1.0", "jest-environment-jsdom": "^28.1.0", "playwright": "^1.27.1", "react": "^18.1.0", "react-dom": "^18.1.0", "rollup": "^2.73.0", "rollup-plugin-dts": "^4.2.1", "rollup-plugin-terser": "^7.0.2", "serve": "^14.1.1", "ts-jest": "^28.0.2", "ts-node": "^10.7.0", "typescript": "^5.7.3" }, "overrides": { "@types/node": "^22.10.10", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" }, "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" } ================================================ FILE: packages/captioning/README.md ================================================ # @liqvid/captioning This package provides audio transcription and captioning utilities for [Liqvid](https://liqvidjs.org). It is used internally by [@liqvid/cli](../cli). ================================================ FILE: packages/captioning/package.json ================================================ { "name": "@liqvid/captioning", "version": "1.0.0", "description": "Audio transcription and captioning for Liqvid", "files": ["dist/*"], "main": "dist/index.js", "repository": { "type": "git", "url": "git+https://github.com/liqvidjs/liqvid.git" }, "author": "Yuri Sulyma ", "license": "MIT", "bugs": { "url": "https://github.com/liqvidjs/liqvid/issues" }, "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/renderer#readme", "devDependencies": { "ibm-watson": "^6.2.1" }, "peerDependencies": { "ibm-watson": "^6.2.1" }, "peerDependenciesMeta": { "ibm-watson": { "optional": true } } } ================================================ FILE: packages/captioning/src/index.ts ================================================ export {transcribe} from "./transcription"; export {toWebVTT} from "./webvtt"; ================================================ FILE: packages/captioning/src/transcription.ts ================================================ import fs, {promises as fsp} from "fs"; import {IamAuthenticator} from "ibm-watson/auth"; import SpeechToTextV1 from "ibm-watson/speech-to-text/v1"; import path from "path"; import {toWebVTT} from "./webvtt"; /** * Transcript with per-word timings */ export type Transcript = [string, number, number][][]; /** * Transcribe audio file */ export async function transcribe(args: { /** Path to audio file */ input: string; /** Path for WebVTT captions */ captions: string; /** Params to pass to IBM Watson. */ params: Partial[0]>; /** Path for rich transcript */ transcript: string; /** IBM Cloud API key */ apiKey: string; /** IBM Watson endpoint URL */ apiUrl: string; }) { const filename = path.resolve(process.cwd(), args.input); const output = path.resolve(process.cwd(), args.transcript); const extn = path.extname(filename); // SpeechToText instance const speechToText = new SpeechToTextV1({ authenticator: new IamAuthenticator({ apikey: args.apiKey, }), serviceUrl: args.apiUrl, }); const params = Object.assign( { audio: fs.createReadStream(filename), contentType: `audio/${extn.slice(1)}`, objectMode: true, model: "en-US_BroadbandModel", profanityFilter: false, smartFormatting: true, timestamps: true, }, args.params, ); // transcribe const {result: json} = await speechToText.recognize(params); await fsp.writeFile(args.transcript, JSON.stringify(json, null, 2)); // format const blockSize = 8; const words = json.results .map((_) => _.alternatives[0].timestamps) .reduce((a, b) => a.concat(b), [] as [string, number, number][]) .map( ([word, t1, t2]: [string, number, number]) => [word, Math.floor(t1 * 1000), Math.floor(t2 * 1000)] as [ string, number, number, ], ); const blocks: Transcript = []; for (let i = 0; i < words.length; i += blockSize) { blocks.push(words.slice(i, i + blockSize)); } // save new version let str = JSON.stringify(blocks, null, 2); str = str.replace(/(? " + formatTimeMs(line[line.length - 1][2]), ); captions.push(line.map((_) => _[0]).join(" ")); captions.push(""); } return captions.join("\n"); } /* WebVTT requires mm:ss whereas @liqvid/utils/time produces [m]m:ss */ function formatTime(time: number): string { if (time < 0) { return "-" + formatTime(-time); } const minutes = Math.floor(time / 60 / 1000), seconds = Math.floor((time / 1000) % 60); return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } function formatTimeMs(time: number): string { if (time < 0) { return "-" + formatTimeMs(-time); } const milliseconds = Math.floor(time % 1000); return `${formatTime(time)}.${milliseconds.toString().padStart(3, "0")}`; } ================================================ FILE: packages/captioning/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "composite": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["./src"] } ================================================ FILE: packages/cli/liqvid-cli.mjs ================================================ #! /usr/bin/env node import * as pkg from "./dist/index.mjs"; pkg .main() .then(() => process.exit(0)) .catch((err) => { // eslint-disable-next-line no-console console.error(err); process.exit(1); }); ================================================ FILE: packages/cli/package.json ================================================ { "name": "@liqvid/cli", "version": "1.0.5", "description": "Liqvid command line utility", "main": "dist/index.js", "bin": { "liqvid": "liqvid-cli.mjs" }, "files": ["dist/*", "liqvid-cli.mjs"], "sideEffects": false, "repository": { "type": "git", "url": "git+https://github.com/liqvidjs/liqvid.git" }, "author": "Yuri Sulyma ", "license": "MIT", "bugs": { "url": "https://github.com/liqvidjs/liqvid/issues" }, "homepage": "https://github.com/liqvidjs/liqvid#readme", "scripts": { "lint": "eslint --ext mts,ts --fix src/" }, "devDependencies": { "@types/cli-progress": "^3.9.2", "@types/yargs": "^17.0.10" }, "dependencies": { "@liqvid/captioning": "workspace:^", "@liqvid/magic": "workspace:^", "@liqvid/renderer": "workspace:^", "@liqvid/server": "workspace:^", "@liqvid/utils": "workspace:^", "@types/node": "^17.0.23", "cli-progress": "^3.10.0", "execa": "^6.1.0", "ts-node": "^10.7.0", "webpack": "^5.70.0", "yargs": "^17.4.1", "yargs-parser": "^21.0.1" } } ================================================ FILE: packages/cli/src/index.mts ================================================ import {readFile} from "fs/promises"; import * as path from "path"; import {fileURLToPath} from "url"; import yargs from "yargs"; import {hideBin} from "yargs/helpers"; // shared options import {audio} from "./tasks/audio.mjs"; import {build} from "./tasks/build.mjs"; import {serve} from "./tasks/serve.mjs"; import {render} from "./tasks/render.mjs"; import {thumbs} from "./tasks/thumbs.mjs"; // entry export async function main() { let config = // WTF yargs(hideBin(process.argv)) .scriptName("liqvid") .strict() .usage("$0 [args]") .demandCommand(1, "Must specify a command"); config = audio(config); config = build(config); config = serve(config); config = render(config); config = thumbs(config); // version const __dirname = path.dirname(fileURLToPath(import.meta.url)); const {version} = JSON.parse( await readFile(path.join(__dirname, "..", "package.json"), "utf8"), ); config.version(version); return config.help().argv; } import type {createServer} from "@liqvid/server"; import type {solidify, thumbs as captureThumbs} from "@liqvid/renderer"; import type {buildProject} from "./tasks/build.mjs"; import type {transcribe} from "@liqvid/captioning"; /** * Configuration object */ export interface LiqvidConfig { audio?: { transcribe: Partial[0]>; }; build?: Partial[0]>; render?: Partial[0]>; serve?: Partial[0]>; thumbs?: Partial[0]>; } ================================================ FILE: packages/cli/src/tasks/audio.mts ================================================ import path from "path"; import type Yargs from "yargs"; import {DEFAULT_CONFIG, parseConfig} from "./config.mjs"; /** * Audio utilities */ export const audio = (yargs: typeof Yargs) => yargs.command("audio", "Audio helpers", (yargs) => { return ( yargs // convert command .command( "convert ", "Repair and convert webm recordings", (yargs) => yargs.positional("filename", { describe: "WebM File to convert", type: "string", }), async (opts) => { const {convert} = await import("@liqvid/renderer/convert"); await convert(opts); }, ) // join command .command( "join [filenames..]", "Join audio files into a single file", (yargs) => yargs .positional("filenames", { desc: "Filenames to join", coerce: (filenames: string[]) => filenames ? filenames.map((_) => path.resolve(_)) : [], }) .option("output", { alias: "o", desc: "Output file. If not specified, defaults to last input filename.", coerce: (output?: string) => output ? path.resolve(output) : output, }), async (opts) => { const {join} = await import("@liqvid/renderer/join"); await join(opts); }, ) // transcribe command .command( "transcribe", "Transcribe audio", (yargs) => yargs .config("config", parseConfig("audio", "transcribe")) .default("config", DEFAULT_CONFIG) .option("api-key", { desc: "IBM API key", demandOption: true, type: "string", }) .option("api-url", { desc: "IBM Watson endpoint URL", demandOption: true, type: "string", }) .option("input", { alias: "i", desc: "Audio filename", normalize: true, demandOption: true, }) .option("captions", { alias: "c", default: "./captions.vtt", desc: "Captions input filename", normalize: true, }) .option("transcript", { alias: "t", default: "./transcript.json", desc: "Rich transcript filename", normalize: true, }) .option("params", { desc: "Parameters for IBM Watson", default: {}, }), async (opts) => { const {transcribe} = await import("@liqvid/captioning"); await transcribe(opts); }, ) .demandCommand(1, "Must specify an audio command") ); }); ================================================ FILE: packages/cli/src/tasks/build.mts ================================================ import { ScriptData, scripts as defaultScripts, StyleData, styles as defaultStyles, transform, } from "@liqvid/magic"; import {promises as fsp} from "fs"; import path from "path"; import webpack from "webpack"; import type Yargs from "yargs"; import {DEFAULT_CONFIG, parseConfig} from "./config.mjs"; // @ts-expect-error TypeScript complains about this not being a module import loadSync from "./load-sync.cjs"; /** * Build project */ export const build = (yargs: typeof Yargs) => yargs.command( "build", "Build project", (yargs) => { return yargs .config("config", parseConfig("build")) .default("config", DEFAULT_CONFIG) .option("clean", { alias: "C", default: false, desc: "Delete old dist directory before starting", type: "boolean", }) .option("out", { alias: "o", coerce: path.resolve, desc: "Output directory", default: "./dist", normalize: true, }) .option("static", { alias: "s", coerce: path.resolve, desc: "Static directory", default: "./static", }) .option("scripts", { coerce: coerceScripts, desc: "Script aliases", default: {}, }) .option("styles", { desc: "Style aliases", default: {}, }); }, (args) => { return buildProject(args); }, ); export async function buildProject(config: { /** Clean build directory */ clean: boolean; /** Output directory */ out: string; /** Static directory */ static: string; scripts: Record; styles: Record; }) { // clean build directory if (config.clean) { console.log("Cleaning build directory..."); await fsp.rm(config.out, {force: true, recursive: true}); } // ensure build directory exists await fsp.mkdir(config.out, {recursive: true}); // copy static files console.log("Copying files..."); await buildStatic(config); // webpack console.log("Creating production bundle..."); await buildBundle(config); } /** * Copy over static files. */ async function buildStatic(config: { out: string; static: string; scripts: Record; styles: Record; }) { const staticDir = path.resolve(process.cwd(), config.static); const scripts = Object.assign({}, defaultScripts, config.scripts); const styles = Object.assign({}, defaultStyles, config.styles); await walkDir(staticDir, async (filename) => { const relative = path.relative(staticDir, filename); const dest = path.join(config.out, relative); // apply html magic if (filename.endsWith(".html")) { const file = await fsp.readFile(filename, "utf8"); await idemWrite( dest, transform(file, {mode: "production", scripts, styles}), ); } else if (relative === "bundle.js") { } else { await fsp.mkdir(path.dirname(dest), {recursive: true}); await fsp.copyFile(filename, dest); } }); } /** * Compile bundle in production mode. */ async function buildBundle(config: { out: string; }) { // configure webpack process.env.NODE_ENV = "production"; const webpackConfig = loadSync(path.join(process.cwd(), "webpack.config.js")); webpackConfig.mode = "production"; webpackConfig.output.path = config.out; const compiler = webpack(webpackConfig); // watch return new Promise((resolve) => { compiler.run((err, stats) => { if (err) console.error(err); else { console.info(stats.toString({color: true})); } compiler.close((err, stats) => { resolve(); }); }); }); } /** * Write a file idempotently. */ async function idemWrite(filename: string, data: string) { try { const old = await fsp.readFile(filename, "utf8"); if (old !== data) await fsp.writeFile(filename, data); } catch (e) { await fsp.mkdir(path.dirname(filename), {recursive: true}); await fsp.writeFile(filename, data); } } /** * Recursively walk a directory. */ async function walkDir( dirname: string, callback: (filename: string) => Promise, ) { const files = (await fsp.readdir(dirname)).map((_) => path.join(dirname, _)); await Promise.all( files.map(async (file) => { const stats = await fsp.stat(file); if (stats.isDirectory()) { return walkDir(file, callback); } else { return callback(file); } }), ); } /** * Fix files. */ function coerceScripts( json: Record< string, | { crossorigin?: boolean | string; development?: string; production?: string; } | string >, ) { for (const key in json) { const record = json[key]; if (typeof record === "object") { if ( typeof record.crossorigin === "string" && ["true", "false"].includes(record.crossorigin) ) { record.crossorigin = record.crossorigin === "true"; } } } return json; } ================================================ FILE: packages/cli/src/tasks/config.mts ================================================ import "ts-node/register/transpile-only"; import os from "os"; import path from "path"; // @ts-expect-error TypeScript complains about this not being a module import loadSync from "./load-sync.cjs"; export const DEFAULT_LIST = [ "liqvid.config.ts", "liqvid.config.js", "liqvid.config.json", ]; export const DEFAULT_CONFIG = DEFAULT_LIST[0]; export function parseConfig(...keys: string[]) { return (configPath: string) => { try { return access(loadSync(configPath), keys); } catch (e) { if (e.code === "MODULE_NOT_FOUND") { // default value => assume not specified if (path.join(process.cwd(), DEFAULT_CONFIG) === configPath) { return {}; } throw e; } else { throw e; } } }; } // function require(filename: string) { // return JSON.parse(readFileSync(path.resolve(process.cwd(), filename), "utf8")); // } function access(o: any, keys: string[]): any { if (keys.length === 0) return o; const key = keys.shift(); if (!o[key]) return {}; return access(o[key], keys); } export const BROWSER_EXECUTABLE = { alias: "x", desc: "Path to a Chrome/ium executable. If not specified and a suitable executable cannot be found, one will be downloaded during rendering.", normalize: true, type: "string", } as const; export const CONCURRENCY = { alias: "n", default: Math.floor(os.cpus().length / 2), desc: "How many threads to use", type: "number", } as const; ================================================ FILE: packages/cli/src/tasks/index.mts ================================================ import {audio} from "./audio.mjs"; import {build} from "./build.mjs"; import {serve} from "./serve.mjs"; import {render} from "./render.mjs"; import {thumbs} from "./thumbs.mjs"; export const commands = [audio, build, render, serve, thumbs]; ================================================ FILE: packages/cli/src/tasks/load-sync.cts ================================================ module.exports = function loadSync(path: string) { return require(path); }; ================================================ FILE: packages/cli/src/tasks/render.mts ================================================ import {parseTime} from "@liqvid/utils/time"; import type Yargs from "yargs"; import { BROWSER_EXECUTABLE, CONCURRENCY, DEFAULT_CONFIG, parseConfig, } from "./config.mjs"; /** Render to static video. */ export const render = (yargs: typeof Yargs) => yargs.command( "render", "Render static video", (yargs) => yargs .config("config", parseConfig("render")) .default("config", DEFAULT_CONFIG) .example([ ["liqvid render"], ["liqvid render -a ./audio/audio.webm -o video.webm"], ["liqvid render -u http://localhost:8080/dist/"], ]) // Selection .group(["audio-file", "output", "url"], "What to render") .option("audio-file", { alias: "a", desc: "Path to audio file", normalize: true, }) .option("output", { alias: "o", default: "./video.mp4", desc: "Output filename", normalize: true, demandOption: true, }) .option("url", { alias: "u", desc: "URL of video to render", default: "http://localhost:3000/dist/", }) // General configuration .group( ["browser-executable", "concurrency", "config", "help"], "General options", ) .option("browser-executable", BROWSER_EXECUTABLE) .option("concurrency", CONCURRENCY) // Input options .group( ["duration", "end", "sequence", "start", "color-scheme"], "Input options", ) .option("start", { alias: "s", coerce: coerceTime, default: "00:00", desc: "Start time, specify as [hh:]mm:ss[.ms]", type: "string", }) .option("duration", { alias: "d", conflicts: "end", desc: "Duration, specify as [hh:]mm:ss[.ms]", type: "string", }) .coerce("duration", coerceTime) .option("end", { alias: "e", desc: "End time, specify as [hh:]mm:ss[.ms]", type: "string", }) .coerce("end", coerceTime) .option("sequence", { alias: "S", desc: "Output image sequence instead of video. If this flag is set, --output will be interpreted as a directory.", type: "boolean", }) .option("color-scheme", { default: "light" as "light" | "dark", choices: ["light", "dark"] as const, desc: "Color scheme", }) // Frames .group( ["height", "image-format", "quality", "width"], "Frame formatting", ) .option("height", { alias: "h", default: 800, desc: "Video height", }) .option("image-format", { alias: "F", choices: ["jpeg", "png"] as const, default: "jpeg" as "jpeg" | "png", desc: "Image format for frames", }) .option("quality", { alias: "q", default: 80, desc: 'Quality for images. Only applies when --image-format is "jpeg"', }) .option("width", { alias: "w", default: 1280, desc: "Video width", }) // ffmpeg .group( ["audio-args", "fps", "pixel-format", "video-args"], "Video options", ) .option("audio-args", { alias: "A", desc: "Additional flags to pass to ffmpeg, applying to the audio file", type: "string", }) .option("fps", { alias: "r", default: 30, desc: "Frames per second", }) .option("pixel-format", { alias: "P", default: "yuv420p", desc: "Pixel format for ffmpeg", }) .option("video-args", { alias: "V", desc: "Additional flags to pass to ffmpeg, applying to the output video", type: "string", }) .version(false), async (argv) => { const {solidify} = await import("@liqvid/renderer/solidify"); await solidify(argv); process.exit(0); }, ); function coerceTime(v: string): number { if (typeof v === "undefined") { return v; } try { return parseTime(v); } catch (e) { console.error(`Invalid time: ${v}. Specify as [hh:]mm:ss[.ms]`); process.exit(1); } } ================================================ FILE: packages/cli/src/tasks/serve.mts ================================================ import path from "path"; import type Yargs from "yargs"; import {DEFAULT_CONFIG, parseConfig} from "./config.mjs"; /** * Run preview server */ export const serve = (yargs: typeof Yargs) => yargs.command( "serve", "Run preview server", (yargs) => yargs .config("config", parseConfig("serve")) .default("config", DEFAULT_CONFIG) .option("build", { alias: "b", coerce: path.resolve, desc: "Build directory", default: "./dist", }) .option("livereload-port", { alias: "L", desc: "Port to run LiveReload on", default: 0, }) .option("port", { alias: "p", desc: "Port to run on", default: 3000, }) .option("static", { alias: "s", coerce: path.resolve, desc: "Static directory", default: "./static", }) .option("scripts", { desc: "Script aliases", default: {}, }) .option("styles", { desc: "Style aliases", default: {}, }), async (argv) => { const {createServer} = await import("@liqvid/server"); // await so script doesn't close await new Promise(() => { createServer(argv); }); }, ); ================================================ FILE: packages/cli/src/tasks/thumbs.mts ================================================ import type Yargs from "yargs"; import { BROWSER_EXECUTABLE, CONCURRENCY, DEFAULT_CONFIG, parseConfig, } from "./config.mjs"; export const thumbs = (yargs: typeof Yargs) => yargs.command( "thumbs", "Generate thumbnails", (yargs) => yargs .config("config", parseConfig("thumbs")) .default("config", DEFAULT_CONFIG) .example([ ["liqvid thumbs"], [ "liqvid thumbs -u http://localhost:8080/dist/ -o ./dist/thumbs/%s.jpeg", ], ]) // Selection .group(["output", "url"], "What to render") .option("output", { alias: "o", default: "./thumbs/%s.jpeg", desc: "Pattern for output filenames.", normalize: true, }) .option("url", { alias: "u", desc: "URL of video to generate thumbs for", default: "http://localhost:3000/dist/", }) // General .group( ["browser-executable", "concurrency", "config", "help"], "General options", ) .option("browser-executable", BROWSER_EXECUTABLE) .option("concurrency", CONCURRENCY) // Format .group( [ "color-scheme", "browser-height", "browser-width", "cols", "frequency", "height", "image-format", "quality", "rows", "width", ], "Formatting", ) .option("color-scheme", { default: "light" as "light" | "dark", choices: ["light", "dark"] as const, desc: "Color scheme", }) .option("cols", { alias: "c", default: 5, desc: "The number of columns per sheet", }) .option("frequency", { alias: "f", default: 4, desc: "How many seconds between screenshots", }) .option("rows", { alias: "r", default: 5, desc: "The number of rows per sheet", }) .option("quality", { alias: "q", default: 80, desc: 'Quality for images. Only applies when --image-format is "jpeg"', }) .option("height", { alias: "h", default: 100, desc: "Height of each thumbnail", }) .option("width", { alias: "w", default: 160, desc: "Width of each thumbnail", }) .option("browser-height", { alias: "H", desc: "Height of screenshot before resizing", type: "number", }) .option("browser-width", { alias: "W", desc: "Width of screenshot before resizing", type: "number", }) .option("image-format", { alias: "F", choices: ["jpeg", "png"] as const, default: "jpeg" as "jpeg" | "png", desc: "Image format for thumbnails", }) .version(false), async (argv) => { const {thumbs: renderThumbs} = await import("@liqvid/renderer/thumbs"); await renderThumbs(argv); process.exit(0); }, ); ================================================ FILE: packages/cli/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "composite": true, "esModuleInterop": true, "outDir": "./dist", "rootDir": "./src", "module": "esnext", "target": "esnext" }, "include": ["./src"] } ================================================ FILE: packages/diff/CHANGELOG.md ================================================ ## 1.1.0 (April 15, 2024) Add generics ## 1.0.0 (April 14, 2024) Initial release ================================================ FILE: packages/diff/README.md ================================================ # @liqvid/diff This package provides functions to diff Javascript objects and arrays. It is used internally by recording plugins, and as such aims to produce very compact output. ================================================ FILE: packages/diff/jest.config.js ================================================ module.exports = { preset: "ts-jest", testPathIgnorePatterns: ["dist"], transform: {}, }; ================================================ FILE: packages/diff/package.json ================================================ { "name": "@liqvid/diff", "version": "1.1.0", "description": "Object-diffing utility", "exports": { ".": { "import": "./dist/esm/index.mjs", "require": "./dist/cjs/index.cjs", "types": "./dist/types/index.d.ts" } }, "typesVersions": { "*": { "*": ["./dist/types/*.d.ts"] } }, "files": ["dist/*"], "scripts": { "build": "pnpm build:clean; pnpm build:js; pnpm build:postclean", "build:clean": "rm -rf dist", "build:js": "tsc --outDir dist/esm --module esnext; tsc --outDir dist/cjs --module commonjs; node ../../build.mjs", "build:postclean": "rm dist/tsconfig.tsbuildinfo", "lint": "eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests", "test": "eslint src --ext ts,tsx && jest --coverage" }, "repository": { "type": "git", "url": "git+https://github.com/liqvidjs/liqvid.git" }, "author": "Yuri Sulyma ", "license": "MIT", "bugs": { "url": "https://github.com/liqvidjs/liqvid/issues" }, "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/diff#readme", "dependencies": { "@liqvid/utils": "workspace:^" }, "sideEffects": false } ================================================ FILE: packages/diff/src/apply.ts ================================================ import type {ArrayDiff, ObjectDiff} from "./types"; import {matchItemDiff, matchRunes, objectKeys} from "./utils"; /** * Apply a diff to an object. * @param a - The object to apply the diff to. * @param b - The diff to apply. * @returns A new object with the diff applied. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function applyDiff(a: T, b: ObjectDiff): T { const copy = structuredClone(a); for (const rkey of objectKeys(b)) { matchRunes(b, rkey, { create(key, item) { // eslint-disable-next-line @typescript-eslint/no-explicit-any copy[key] = item as any; }, delete(key) { delete copy[key]; }, array(key, item) { const target = copy[key]; if (!Array.isArray(target)) { throw new TypeError("Expected array"); } // eslint-disable-next-line @typescript-eslint/no-explicit-any copy[key] = applyArrayDiff(target, item) as any; }, object(key, item) { const target = copy[key]; if (typeof target !== "object" || target === null) { throw new TypeError("Expected object"); } copy[key] = applyDiff(target, item); }, change(key, item) { // eslint-disable-next-line @typescript-eslint/no-explicit-any copy[key] = item as any; }, }); } return copy; } /** * Apply a diff to an array. * @param arr - The array to apply the diff to. * @param diff - The diff to apply. * @returns A new array with the diff applied. */ export function applyArrayDiff(arr: T[], diff: ArrayDiff): T[] { const [delta, itemDiffs = [], ...appends] = diff; const copy = arr.slice(); for (const diff of itemDiffs) { matchItemDiff(diff, { set(offset, item) { copy[copy.length - offset] = item as T; }, array(offset, item) { copy[copy.length - offset] = applyArrayDiff( copy[copy.length - offset] as unknown[], item, ) as T; }, object(offset, item) { copy[copy.length - offset] = applyDiff( copy[copy.length - offset], item, ) as T; }, }); } if (delta < 0) { copy.splice(copy.length + delta, -delta); } else { for (const append of appends) { copy.push(append as T); } } return copy; } ================================================ FILE: packages/diff/src/builders.ts ================================================ import {deletePlaceholder, runes} from "./runes"; import type { ArrayDiff, ArrayItemDiff, ChangeItemDiff, DeletePlaceholder, ObjectDiff, RunedKey, } from "./types"; /** * Make a diff to create a value. * @param key Key to use. * @param value Value to create. */ export function creationDiff(key: string, value: V) { return {[`${runes.create}${key}`]: value} as Record, V>; } /** * Make a diff to delete a value. * @param key Key to use. * @returns Diff to delete the value. */ export function deletionDiff(key: K) { return {[`${runes.delete}${key}`]: deletePlaceholder} as Record< RunedKey<"delete", K>, DeletePlaceholder >; } /** * Make a diff to update an array. * @param key Key to use. * @param diff Array diff to apply. */ export function arrayDiff>( key: K, diff: D, ) { return {[`${runes.array}${key}`]: diff} as Record, D>; } /** * Make a diff to update an object. * @param key Key to use. * @param diff Object diff to apply. */ export function objectDiff>( key: K, diff: D, ) { return {[`${runes.object}${key}`]: diff} as Record, D>; } /** * Make a diff to set a value. * @param key Key to use. * @value Value to set. */ export function changeDiff(key: string, value: V) { return {[`${runes.change}${key}`]: value} as Record, V>; } // item diffs /** * Make an item diff to change a value. * @param offset Offset from the end to change. * @param value Value to change to. */ export function changeItemDiff(offset: number, value: T): ChangeItemDiff { return [offset, value]; } /** * Make an item diff to change an array item. * @param offset Offset from the end to change. * @param diff Array diff to apply. */ export function arrayItemDiff( offset: number, diff: ArrayDiff, ): ArrayItemDiff { return [`${runes.array}${offset}`, diff]; } /** * Make an item diff to change an object item. * @param index Index to change. * @param diff Object diff to apply. */ export function objectItemDiff>( offset: number, diff: D, ) { return [`${runes.object}${offset}`, diff] as [RunedKey<"object">, D]; } ================================================ FILE: packages/diff/src/compute.ts ================================================ import {assertType} from "@liqvid/utils/types"; import { arrayDiff, arrayItemDiff, changeDiff, creationDiff, deletionDiff, objectDiff, objectItemDiff, } from "./builders"; import type {ArrayDiff, ObjectDiff} from "./types"; import {cmp} from "./utils"; /** Compute the diff between two arrays. */ export function diffArrays(a: T[], b: T[]): ArrayDiff { // diffs const itemDiffs: Exclude[1], undefined> = []; for (let i = 0; i < Math.min(a.length, b.length); ++i) { const itemA = a[i]; const itemB = b[i]; const offset = a.length - i; if (!cmp(itemA, itemB)) { // simple replace if ( typeof itemA !== typeof itemB || typeof itemA === "bigint" || typeof itemA === "boolean" || typeof itemA === "number" || typeof itemA === "string" || itemA === null || itemB === null || Array.isArray(itemA) != Array.isArray(itemB) ) { itemDiffs.push([offset, itemB]); continue; } if (Array.isArray(itemA)) { assertType(itemB); // eslint-disable-next-line @typescript-eslint/no-explicit-any itemDiffs.push(arrayItemDiff(offset, diffArrays(itemA, itemB))); } else { assertType>(itemA); assertType>(itemB); itemDiffs.push(objectItemDiff(offset, diffObjects(itemA, itemB))); } } } const delta = b.length - a.length; // pure deletion if (itemDiffs.length === 0 && delta <= 0) { return [delta]; } else { return [b.length - a.length, itemDiffs, ...b.slice(a.length)]; } } /** * Compute the diff between two objects. */ export function diffObjects(a: T, b: T): ObjectDiff { assertType>(a); assertType>(b); const ret: ObjectDiff = {}; const keysA = Object.keys(a); const keysB = new Set(Object.keys(b)); for (const key of keysA) { if (!keysB.has(key)) { Object.assign(ret, deletionDiff(key)); continue; } const valueA = a[key] as unknown; const valueB = b[key] as unknown; if (typeof valueA !== typeof valueB) { console.warn("Expected same type"); } else { if (!cmp(valueA, valueB)) { switch (typeof valueA) { case "string": case "number": case "boolean": case "bigint": Object.assign(ret, changeDiff(key, valueB)); break; case "object": if ( valueA === null || valueB === null || Array.isArray(valueA) !== Array.isArray(valueB) ) { Object.assign(ret, changeDiff(key, valueB)); } else if (Array.isArray(valueA)) { assertType(valueB); Object.assign(ret, arrayDiff(key, diffArrays(valueA, valueB))); } else { assertType(valueB); Object.assign(ret, objectDiff(key, diffObjects(valueA, valueB))); } break; } } } keysB.delete(key); } for (const key of keysB) { Object.assign(ret, creationDiff(key, b[key])); } return ret; } ================================================ FILE: packages/diff/src/index.ts ================================================ export {applyArrayDiff, applyDiff} from "./apply"; export { arrayDiff, arrayItemDiff, changeDiff, changeItemDiff, creationDiff, deletionDiff, objectDiff, objectItemDiff, } from "./builders"; export {diffArrays, diffObjects as diffObjects} from "./compute"; export {mergeArrayDiffs, mergeDiffs} from "./merge"; export type { ArrayDiff, ArrayItemDiff, ChangeItemDiff, DeletePlaceholder, ItemDiff, ObjectDiff, ObjectItemDiff, Rune, RuneName, RunedKey, } from "./types"; export {cmp, invertDiff, matchItemDiff, matchRunes} from "./utils"; ================================================ FILE: packages/diff/src/merge.ts ================================================ import {assertDefined, assertType} from "@liqvid/utils/types"; import {applyArrayDiff, applyDiff} from "./apply"; import { arrayDiff, arrayItemDiff, changeDiff, changeItemDiff, creationDiff, deletionDiff, objectDiff, objectItemDiff, } from "./builders"; import type {ArrayDiff, ItemDiff, ObjectDiff} from "./types"; import { consume, getOffset, matchItemDiff, matchRunes, objectKeys, } from "./utils"; /** Merge two array diffs. */ export function mergeArrayDiffs( a: ArrayDiff, b: ArrayDiff, ): ArrayDiff { const [deltaA, itemDiffsA = [], ...tailA] = a; const [deltaB, itemDiffsB = [], ...tailB] = b; const delta = deltaA + deltaB; // combine item diffs const itemDiffs: ItemDiff[] = []; let iterA = 0; let iterB = 0; for (; iterA < itemDiffsA.length || iterB < itemDiffsB.length; ) { const itemA = itemDiffsA.at(iterA); const itemB = itemDiffsB.at(iterB); const offsetA = itemA ? getOffset(itemA[0]) : 0; const offsetB = itemB ? getOffset(itemB[0]) : 0; const newOffsetB = offsetB - deltaA; if (offsetA > newOffsetB) { assertDefined(itemA); // skip if deleted by b if (offsetA > -deltaB) { itemDiffs.push(itemA); } iterA++; } else if (newOffsetB > offsetA) { assertDefined(itemB); if (deltaA >= 0) { // adjust the tail of A if (offsetB <= tailA.length) { const tailOffset = tailA.length - offsetB; const valueA = tailA[tailOffset]; matchItemDiff(itemB, { // set set(_, valueB) { tailA[tailOffset] = valueB; }, // array array(_, valueB) { assertType(valueA); tailA[tailOffset] = applyArrayDiff(valueA, valueB); }, object(_, valueB) { assertType>(valueA); tailA[tailOffset] = applyDiff(valueA, valueB); }, }); } else { itemDiffs.push([newOffsetB, itemB[1]] as ItemDiff); } } else { itemDiffs.push([newOffsetB, itemB[1]] as ItemDiff); } iterB++; } else { assertDefined(itemA); assertDefined(itemB); // offsetA === newOffsetB matchItemDiff(itemA, { set(_, valueA) { matchItemDiff(itemB, { // change(a) * change(b) = change(b) set(_, valueB) { // eslint-disable-next-line @typescript-eslint/no-explicit-any itemDiffs.push(changeItemDiff(offsetA, valueB)); }, // change(a) * array(b) = change(a*b) array(_, valueB) { assertType(valueA); itemDiffs.push( // eslint-disable-next-line @typescript-eslint/no-explicit-any changeItemDiff(offsetA, applyArrayDiff(valueA, valueB)), ); }, // change(a) * object(b) = change(a*b) object(_, valueB) { assertType(valueA); itemDiffs.push( // eslint-disable-next-line @typescript-eslint/no-explicit-any changeItemDiff(offsetA, applyDiff(valueA, valueB)), ); }, }); }, array(_, valueA) { matchItemDiff(itemB, { // array(a) * change(b) = change(b) set(_, valueB) { // eslint-disable-next-line @typescript-eslint/no-explicit-any itemDiffs.push(changeItemDiff(offsetA, valueB)); }, // array(a) * array(b) = array(a*b) array(_, valueB) { itemDiffs.push( // eslint-disable-next-line @typescript-eslint/no-explicit-any arrayItemDiff(offsetA, mergeArrayDiffs(valueA, valueB)), ); }, }); }, object(_, valueA) { matchItemDiff(itemB, { // object(a) * change(b) = change(b) set(_, valueB) { // eslint-disable-next-line @typescript-eslint/no-explicit-any itemDiffs.push(changeItemDiff(offsetA, valueB)); }, // object(a) * object(b) = object(a*b) object(_, valueB) { itemDiffs.push( objectItemDiff(offsetA, mergeDiffs(valueA, valueB)), ); }, }); }, }); iterA++; iterB++; } } // needs to come afterwards since we modify tailA above const tail = [ ...tailA.slice(0, tailA.length + Math.min(0, deltaB)), ...tailB, ]; return [delta, itemDiffs, ...tail]; } /** Merge two object diffs. */ export function mergeDiffs( a: ObjectDiff, b: ObjectDiff, ): ObjectDiff { const ret: ObjectDiff = {}; for (const rKeyB of objectKeys(b)) { matchRunes(b, rKeyB, { // create create(key, valueB) { consume(a, key, { // delete * create(b) = set(b) delete() { Object.assign(ret, changeDiff(key, valueB)); }, none() { Object.assign(ret, creationDiff(key, valueB)); }, else(name) { throw new Error(`Invalid merge: ${name}-add`); }, }); }, // delete delete(key) { consume(a, key, { delete() { throw new Error("Invalid merge: delete-delete"); }, }); Object.assign(ret, deletionDiff(key)); }, // set change(key, valueB) { consume(a, key, { // create * set(b) = create(b) create() { Object.assign(ret, creationDiff(key, valueB)); }, // invalid delete() { throw new Error("Invalid merge: delete-set"); }, // _ * set(b) = set(b) else() { Object.assign(ret, changeDiff(key, valueB)); }, none() { Object.assign(ret, changeDiff(key, valueB)); }, }); }, // array array(key, valueB) { consume(a, key, { // create(a) * array(b) = create(a*b) create(valueA) { assertType(valueA); Object.assign( ret, creationDiff(key, applyArrayDiff(valueA, valueB)), ); }, // set(a) * array(b) = set(a*b) change(valueA) { assertType(valueA); Object.assign(ret, changeDiff(key, applyArrayDiff(valueA, valueB))); }, else(name) { throw new Error(`Invalid merge: ${name}-array`); }, array(valueA) { Object.assign(ret, arrayDiff(key, mergeArrayDiffs(valueA, valueB))); }, none() { Object.assign(ret, arrayDiff(key, valueB)); }, }); }, // object object(key, valueB) { consume(a, key, { // create(a) * object(b) = object(a*b) create(valueA) { assertType(valueA); Object.assign(ret, creationDiff(key, applyDiff(valueA, valueB))); }, // set(a) * object(b) = set(a*b) change(valueA) { assertType(valueA); Object.assign(ret, changeDiff(key, applyDiff(valueA, valueB))); }, else(name) { throw new Error(`Invalid merge: ${name}-array`); }, // object(a) * object(b) = object(a*b) object(valueA) { Object.assign(ret, objectDiff(key, mergeDiffs(valueA, valueB))); }, none() { Object.assign(ret, objectDiff(key, valueB)); }, }); }, }); } // add anything remaining from a Object.assign(ret, a); return ret; } ================================================ FILE: packages/diff/src/runes.ts ================================================ export const runes = { array: "#", change: "=", create: "+", delete: "-", object: "@", } as const; export const deletePlaceholder = 0; ================================================ FILE: packages/diff/src/types.ts ================================================ import type {deletePlaceholder, runes} from "./runes"; // runes export type RuneName = keyof typeof runes; export type Rune = (typeof runes)[RuneName]; export type RunedKey< K extends RuneName, Name extends string = string, > = `${(typeof runes)[K]}${Name}`; // array diffs export type ChangeItemDiff = [offset: number, value: T]; export type ObjectItemDiff = [ offset: RunedKey<"object">, diff: ObjectDiff, ]; export type ArrayItemDiff = [offset: RunedKey<"array">, diff: ArrayDiff]; /** * Note that offsets are relative to the **end** of the array. */ export type ItemDiff = | ArrayItemDiff | ChangeItemDiff | ObjectItemDiff; /** * A record describing how to make changes to an array. */ export type ArrayDiff = [ delta: number, itemDiffs?: ItemDiff[], ...tail: unknown[], ]; // delete placeholder export type DeletePlaceholder = typeof deletePlaceholder; /** * A record describing how to make changes to an object. */ export type ObjectDiff = { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: RunedKey<"array">]: ArrayDiff; [key: RunedKey<"change">]: unknown; [key: RunedKey<"create">]: unknown; [key: RunedKey<"delete">]: DeletePlaceholder; [key: RunedKey<"object">]: ObjectDiff; }; ================================================ FILE: packages/diff/src/utils.ts ================================================ import {assertType} from "@liqvid/utils/types"; import {applyDiff} from "./apply"; import {diffObjects} from "./compute"; import {runes} from "./runes"; import type { ArrayDiff, ItemDiff, ObjectDiff, Rune, RuneName, RunedKey, } from "./types"; /** Typed {@link Object.keys} */ export function objectKeys(obj: T): (keyof T)[] { return Object.keys(obj) as (keyof T)[]; } /** Comparison function */ export function cmp(a: unknown, b: unknown): boolean { if (typeof a !== typeof b) return false; switch (typeof a) { case "bigint": case "boolean": case "function": case "number": case "string": case "symbol": case "undefined": return a === b; } if (a === null || b === null) return a === b; assertType>(a); assertType>(b); const keysA = Object.keys(a); const keysB = new Set(Object.keys(b)); if (keysA.length !== keysB.size) return false; return keysA.every((key) => keysB.has(key) && cmp(a[key], b[key])); } /** * Pattern-match on an item diff. */ export function matchItemDiff( [offset, item]: ItemDiff, fns: { set?: (offset: number, value: unknown) => R; array?: (offset: number, value: ArrayDiff) => R; object?: (offset: number, value: ObjectDiff) => R; }, ): R | undefined { if (typeof offset === "number") { return fns.set?.(offset, item); } const numeric = getOffset(offset); if (isRune(offset, runes.array)) { assertType>(item); return fns?.array?.(numeric, item); } else if (isRune(offset, runes.object)) { assertType>(item); return fns?.object?.(numeric, item); } } export function isRune( key: string, rune: R, ): key is `${R}${string}` { return key.startsWith(rune as string); } /** Pattern-match on an object diff. */ export function matchRunes( diff: ObjectDiff, key: keyof ObjectDiff, fns: { [name in RuneName]?: ( key: string & keyof T, rkey: ObjectDiff[RunedKey], ) => R; }, ): R | undefined { for (const name of Object.keys(fns) as RuneName[]) { const rune = runes[name]; if (key.startsWith(rune)) { const fn = fns[name]; // eslint-disable-next-line @typescript-eslint/no-explicit-any return fn(key.slice(rune.length) as any, diff[key] as any); } } } export function consume( a: ObjectDiff, key: string, fns: { [$name in RuneName | "else" | "none"]?: $name extends RuneName ? (value: ObjectDiff[RunedKey<$name>]) => unknown : $name extends "else" ? ( name: K, value: ObjectDiff[RunedKey], ) => unknown : () => unknown; } = {}, ) { for (const name of objectKeys(runes)) { const rune = runes[name]; const keyA = `${rune}${key}` as const; if (!(keyA in a)) continue; const valueA = a[keyA]; delete a[keyA]; const fn = fns[name]; if (fn) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return fn(valueA as any); } if (fns.else) { return fns.else(name, valueA); } } // if nothing matched return fns.none?.(); } export function getOffset(offset: ItemDiff[0]): number { if (typeof offset === "number") return offset; if (isRune(offset, runes.array)) { return parseInt(offset.slice(runes.array.length), 10); } else if (isRune(offset, runes.object)) { return parseInt(offset.slice(runes.object.length), 10); } throw new Error(`Invalid index: ${offset}`); } export function addToOffset[0]>( offset: O, delta: number, ): O { const result = getOffset(offset) + delta; if (typeof offset === "number") return result as O; if (isRune(offset, runes.array)) { return `${runes.array}${result}` as O; } return `${runes.object}${result}` as O; } /** * Invert a diff with respect to an object. * Note that it is not possible to invert a lone diff. */ export function invertDiff(state: T, diff: ObjectDiff): ObjectDiff { return diffObjects(applyDiff(state, diff), state); } ================================================ FILE: packages/diff/tests/suite.test.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import {applyDiff, diffObjects} from "../src"; describe("diffObjects and applyDiff", () => { test("property deletion", () => { const a = {x: 1}; const b = {}; const diff = diffObjects(a, b); expect(diff).toEqual({"-x": 0}); expect(applyDiff(a, diff)).toEqual(b); }); test("property additions", () => { const a = {}; const b = {x: 1}; const diff = diffObjects(a, b); expect(diff).toEqual({"+x": 1}); expect(applyDiff(a, diff)).toEqual(b); }); test("property changes", () => { const a = {x: 1}; const b = {x: 2}; const diff = diffObjects(a, b); expect(diff).toEqual({"=x": 2}); expect(applyDiff(a, diff)).toEqual(b); }); test("array appends", () => { const a = {x: [0, 1, 2]}; const b = {x: [0, 1, 2, 3, 4]}; const diff = diffObjects(a, b); expect(diff).toEqual({"#x": [2, [], 3, 4]}); expect(applyDiff(a, diff)).toEqual(b); }); test("array deletions", () => { const a = {x: [0, 1, 2]}; const b = {x: [0]}; const diff = diffObjects(a, b); expect(diff).toEqual({"#x": [-2]}); expect(applyDiff(a, diff)).toEqual(b); }); test("array changes", () => { const a = {x: [0, 1, 2]}; const b = {x: [0, 3]}; const diff = diffObjects(a, b); expect(diff).toEqual({"#x": [-1, [[2, 3]]]}); expect(applyDiff(a, diff)).toEqual(b); }); test("nested objects", () => { const a = {x: {fruit: "apple", color: "red"}}; const b = {x: {fruit: "potato", kind: "mashed"}}; const diff = diffObjects(a, b); expect(diff).toEqual({ "@x": {"=fruit": "potato", "+kind": "mashed", "-color": 0}, }); expect(applyDiff(a, diff)).toEqual(b); }); test("objects nested in arrays", () => { const a = { shapes: { square: { segments: [{type: "free", points: [0, 1]}], }, }, }; const b = { shapes: { square: { segments: [{type: "free", points: [0, 1, 2, 3]}], }, }, }; const diff = diffObjects(a, b); expect(diff).toEqual({ "@shapes": { "@square": { "#segments": [ 0, [ [ "@1", { "#points": [2, [], 2, 3], }, ], ], ], }, }, }); expect(applyDiff(a, diff)).toEqual(b); }); test("kitchen sink", () => { const a = { x: 1, y: 2, z: 3, arr: [1, 2, 3], obj: {fruit: "apple", color: "red"}, }; const b = { x: 3, z: 3, w: 4, arr: [1, 5, 4, "x", "y"], obj: {fruit: "potato", kind: "mashed"}, }; const diff = diffObjects(a, b); expect(diff).toEqual({ "=x": 3, "-y": 0, "+w": 4, "#arr": [ 2, [ [2, 5], [1, 4], ], "x", "y", ], "@obj": { "=fruit": "potato", "+kind": "mashed", "-color": 0, }, }); expect(applyDiff(a, diff)).toEqual(b); }); }); ================================================ FILE: packages/diff/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "composite": true, "declarationDir": "./dist/types", "outDir": "./dist", "rootDir": "./src" }, "include": ["./src"] } ================================================ FILE: packages/duration/README.md ================================================ # @liqvid/duration Provides an opaque `Duration` type to handle relative times, avoiding millisecond/second mismatches. ================================================ FILE: packages/duration/package.json ================================================ { "name": "@liqvid/duration", "version": "1.1.0", "description": "Class for unitless time intervals", "exports": { ".": { "import": "./dist/esm/index.mjs", "require": "./dist/cjs/index.cjs", "types": "./dist/types/index.d.ts" } }, "typesVersions": { "*": { "*": [ "./dist/types/*.d.ts" ] } }, "files": [ "dist/*" ], "scripts": { "build": "pnpm build:clean; pnpm build:js; pnpm build:postclean", "build:clean": "rm -rf dist", "build:js": "pnpm build:js:cjs; pnpm build:js:esm; pnpm build:js:fix", "build:js:cjs": "tsc --module commonjs --outDir dist/cjs", "build:js:esm": "tsc --module esnext --outDir dist/esm", "build:js:fix": "node ../../build.mjs", "build:postclean": "rm dist/tsconfig.tsbuildinfo", "lint": "biome check --fix" }, "repository": { "type": "git", "url": "git+https://github.com/liqvidjs/liqvid.git" }, "author": "Yuri Sulyma ", "license": "MIT", "bugs": { "url": "https://github.com/liqvidjs/liqvid/issues" }, "homepage": "https://github.com/liqvidjs/liqvid/tree/main/packages/duration#readme", "devDependencies": { "@biomejs/biome": "catalog:", "typescript": "catalog:" }, "sideEffects": false } ================================================ FILE: packages/duration/src/index.ts ================================================ const SECONDS = 1000, MINUTES = 60 * SECONDS, HOURS = 60 * MINUTES, DAYS = 24 * HOURS, WEEKS = 7 * DAYS; /** * Convenience type representing either a {@link Duration} * or creation options for one */ export type DurationLike = Duration | DurationOptions; /** * These are additive, e.g. passing `{seconds: 20, minutes: 5}` is * equivalent to passing `{seconds: 320}`. */ export interface DurationOptions { milliseconds?: number; seconds?: number; minutes?: number; hours?: number; days?: number; weeks?: number; } /** * Interval between two points in time, agnostic of units. */ export class Duration { private __valueMs: number; constructor({ milliseconds = 0, seconds = 0, minutes = 0, hours = 0, days = 0, weeks = 0, }: DurationOptions = {}) { this.__valueMs = weeks * WEEKS + days * DAYS + hours * HOURS + minutes * MINUTES + seconds * SECONDS + milliseconds; } /** * Coerce a DurationLike into a Duration */ static from(val: DurationLike): Duration { if (val instanceof Duration) return val; return new Duration(val); } /** * Create a new {@link Duration} object and receive a callback * to imperatively set its value. This is useful when you need * to keep a {@link Duration} object in sync with some changing * value (e.g. wrapping `currentTime` on a `