[
  {
    "path": ".gitignore",
    "content": "packages/**/LICENSE\n\n# Node\nnode_modules\ndist\ncoverage\ntsconfig.tsbuildinfo\n*.log\n\n# Configuration\n.env\n\n# Editors\n*.code-*\n*.sublime-*\n\n# Generated\nbundle.js\n\n# Operating system\n.DS_Store\n\n# static files\n*.mp4\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) Yuri Sulyma\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n"
  },
  {
    "path": "README.md",
    "content": "# Liqvid\n\n[Liqvid](https://liqvidjs.org/) is a library for creating **interactive** videos in React.\n\n## Links\n\n[Documentation](https://liqvidjs.org/docs/)\n\n[Discord](https://discord.gg/u8Qab99zHx)\n\n## Repository structure\n\nThis is a monorepo. Here is what the various packages do:\n\n### Frontend Core\n\n* `main`  \nProvides the main `liqvid` package.\n\n* `host`  \nScript for pages hosting Liqvid videos; currently just handles [fake fullscreen](https://liqvidjs.org/docs/guide/mobile#fake-fullscreen)\n\n* `keymap`  \nProvides the [`Keymap`](https://liqvidjs.org/docs/reference/Keymap) class\n\n* `playback`  \nProvides the [`Playback`](https://liqvidjs.org/docs/reference/Playback) class\n\n* `polyfills`  \nPolyfills for Liqvid videos; currently just handles [Web Animations](https://liqvidjs.org/docs/guide/mobile/#web-animations)\n\n* `utils`  \nProvides the various helper functions in [`Utils`](https://liqvidjs.org/docs/reference/Utils/animation)\n\n### Backend Tools\n\n* `cli`  \nThe Liqvid [CLI tool](https://liqvidjs.org/docs/cli/tool)\n\n* `magic`  \nProvides wacky[resource macro](https://liqvidjs.org/docs/cli/macros) syntax\n\n* `renderer`  \nHandles 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\n\n* `serve`  \nDevelopment server; provides the [`serve`](https://liqvidjs.org/docs/cli/tool) CLI command\n\n### Integrations\n\n* `katex`  \nProvides [KaTeX integration](https://liqvidjs.org/docs/integrations/katex)\n\n* `react-three`  \nProvides [React Three Fiber](https://liqvidjs.org/docs/integrations/three) integration\n\n### In-development\n\n* `captioning`  \nCaptions editor\n\n* `gsap`  \n[GSAP](https://greensock.com/gsap/) integration (maybe already works???)\n\n* `i18n`  \nInternationalization utilities\n\n* `player`  \nNew Web Components-based `<Player>`\n\n* `mathjax`  \n[MathJax](https://www.mathjax.org/) integration\n\n* `react`  \nProbably for when Liqvid goes to Web Components (v3)\n\n* `xyjax`  \n[XyJax](https://github.com/sonoisa/XyJax-v3/) integration\n"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/2.3.8/schema.json\",\n  \"assist\": {\n    \"actions\": {\n      \"source\": {\n        \"organizeImports\": {\n          \"level\": \"on\",\n          \"options\": {\n            \"groups\": [\n              \":NODE:\",\n              \":BLANK_LINE:\",\n\n              \":PACKAGE:\",\n              \":BLANK_LINE:\",\n\n              \":ALIAS:\",\n              \":BLANK_LINE:\",\n\n              [\"../**\", \"!**/*.css\", \"!**/*.json\", \"!**/*.scss\"],\n              \":BLANK_LINE:\",\n\n              [\"./**\", \"!**/*.css\", \"!**/*.json\", \"!**/*.scss\"],\n              \":BLANK_LINE:\",\n\n              [\"**/*.css\", \"**/*.scss\"],\n              \":BLANK_LINE:\",\n\n              \"**/*.json\"\n            ]\n          }\n        },\n        \"useSortedAttributes\": \"on\",\n        \"useSortedKeys\": \"on\"\n      }\n    }\n  },\n  \"css\": {\n    \"parser\": {\n      \"tailwindDirectives\": true\n    }\n  },\n  \"files\": {\n    \"ignoreUnknown\": false,\n    \"includes\": [\n      \"**\",\n      \"!build\",\n      \"!**/dist\",\n      \"!**/package.json\",\n      \"!**/package-lock.json\",\n      \"!**/node_modules\",\n      \"!public/vendor/mathjax/*\",\n      \"!**/recordings/*.json\",\n      \"!**/recordings.json\",\n      \"!**/bundle.js\"\n    ]\n  },\n  \"formatter\": {\n    \"attributePosition\": \"auto\",\n    \"bracketSpacing\": true,\n    \"enabled\": true,\n    \"formatWithErrors\": false,\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2,\n    \"lineEnding\": \"lf\",\n    \"lineWidth\": 80,\n    \"useEditorconfig\": true\n  },\n  \"javascript\": {\n    \"formatter\": {\n      \"arrowParentheses\": \"always\",\n      \"attributePosition\": \"auto\",\n      \"bracketSameLine\": false,\n      \"bracketSpacing\": true,\n      \"jsxQuoteStyle\": \"double\",\n      \"quoteProperties\": \"asNeeded\",\n      \"quoteStyle\": \"double\",\n      \"semicolons\": \"always\",\n      \"trailingCommas\": \"all\"\n    }\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"correctness\": {\n        \"useExhaustiveDependencies\": \"error\",\n        \"useUniqueElementIds\": \"off\"\n      },\n      \"nursery\": {\n        \"useSortedClasses\": {\n          \"fix\": \"safe\",\n          \"level\": \"info\",\n          \"options\": {\n            \"functions\": [\"clsx\", \"cva\", \"tw\", \"classNames\"]\n          }\n        }\n      },\n      \"recommended\": true,\n      \"style\": {\n        \"useTemplate\": \"off\"\n      },\n      \"suspicious\": {\n        \"noUnknownAtRules\": \"off\"\n      }\n    }\n  },\n  \"vcs\": {\n    \"clientKind\": \"git\",\n    \"enabled\": false,\n    \"useIgnoreFile\": true\n  }\n}\n"
  },
  {
    "path": "build.mjs",
    "content": "/* Horrifying fixer-upper for ESM imports */\nimport * as fs from \"fs\";\nimport {existsSync, promises as fsp, readFileSync} from \"fs\";\nimport * as path from \"path\";\n\nconst DIST = path.join(process.cwd(), \"dist\");\nconst NODE_MODULES = path.join(process.cwd(), \"node_modules\");\n\nbuild();\n\nasync function build() {\n  for (const type of [\"esm\", \"cjs\"]) {\n    const dir = path.join(DIST, type);\n    const extn = type === \"esm\" ? \"mjs\" : \"cjs\";\n\n    // rename files first\n    await walkDir(dir, async (filename) => {\n      if (!filename.endsWith(\".js\")) return;\n      await renameExtension(filename, extn);\n    });\n\n    // now fix imports\n    await walkDir(dir, async (filename) => {\n      if (!filename.endsWith(`.${extn}`)) return;\n      await fixImports(filename, type);\n    });\n  }\n}\n\n/**\n * Recursively walk a directory\n * @param {string} dirname Name of directory to walk\n * @param {(filename: string) => Promise<void>} callback Callback\n */\nasync function walkDir(dirname, callback) {\n  const files = (await fsp.readdir(dirname)).map((filename) =>\n    path.join(dirname, filename),\n  );\n\n  /* first rename all files */\n\n  await Promise.all(\n    files.map(async (filename) => {\n      const stat = await fsp.stat(filename);\n      if (stat.isDirectory()) {\n        return walkDir(filename, callback);\n      }\n      await callback(filename);\n    }),\n  );\n}\n\n/**\n * Add extensions to relative imports\n * @param {string} filename File to operate on.\n * @param {\"esm\" | \"cjs\"} type Javascript module system in use.\n */\nasync function fixImports(filename, type = \"esm\") {\n  let content = await fsp.readFile(filename, \"utf8\");\n  const regex =\n    type === \"esm\"\n      ? /^((?:ex|im)port .+? from\\s+)([\"'])(.+?)(\\2;?)$/gm\n      : /(require\\()(['\"])(.+?)(\\2\\))/gm;\n  content = content.replaceAll(regex, (match, head, q, name, tail) => {\n    // already has extension\n    if (name.match(/\\.[cm]?js$/)) {\n      return match;\n    }\n\n    // relative imports\n    if (name.startsWith(\".\")) {\n      // figure out which file it's referring to\n      const target = findExtension(path.dirname(filename), name);\n      return head + q + target + tail;\n    } else {\n    }\n    return match;\n  });\n\n  await fsp.writeFile(filename, content);\n}\n\n/**\n * Find extension\n */\nfunction findExtension(pathname, relative) {\n  const filename = path.resolve(pathname, relative);\n  for (const extn of [\"mjs\", \"js\", \"cjs\"]) {\n    const full = filename + \".\" + extn;\n\n    if (existsSync(full)) {\n      let rewrite = path.relative(pathname, full);\n      if (!rewrite.startsWith(\".\")) {\n        rewrite = \"./\" + rewrite;\n      }\n      return rewrite;\n    }\n  }\n  throw new Error(`Could not resolve ${filename}`);\n}\n\n/**\n * Find package.json\n * @param {string} name Name of package.\n */\nfunction findPackageJson(name) {\n  const packageName = getPackageName(name);\n  let dirname = NODE_MODULES;\n  while (true) {\n    const filename = path.join(dirname, packageName, \"package.json\");\n    if (fs.existsSync(filename)) return filename;\n    dirname = path.normalize(path.join(dirname, \"..\"));\n    if (dirname === \"/\")\n      throw new Error(`Could not find package.json for ${name}`);\n  }\n}\n\n/**\n * Get name of NPM package\n * @param {string} name Import string to resolve.\n */\nfunction getPackageName(name) {\n  const parts = name.split(\"/\");\n  if (name.startsWith(\"@\")) {\n    return parts.slice(0, 2).join(\"/\");\n  }\n  return parts[0];\n}\n\n/**\n * Change file extension\n */\nasync function renameExtension(filename, extn = \"mjs\") {\n  await fsp.rename(filename, filename.replace(/\\.js$/, `.${extn}`));\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"root\",\n  \"private\": true,\n  \"workspaces\": [\"packages/*\"],\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.17.10\",\n    \"@babel/plugin-transform-modules-umd\": \"^7.16.7\",\n    \"@babel/preset-env\": \"^7.17.10\",\n    \"@biomejs/biome\": \"1.9.4\",\n    \"@playwright/experimental-ct-react\": \"^1.27.1\",\n    \"@playwright/test\": \"^1.27.1\",\n    \"@rollup/plugin-babel\": \"^5.3.1\",\n    \"@rollup/plugin-commonjs\": \"^22.0.0\",\n    \"@rollup/plugin-node-resolve\": \"^13.3.0\",\n    \"@testing-library/dom\": \"^8.13.0\",\n    \"@testing-library/react\": \"^13.2.0\",\n    \"@testing-library/user-event\": \"^14.2.0\",\n    \"@types/jest\": \"^27.5.1\",\n    \"@types/node\": \"^22.10.10\",\n    \"@types/react\": \"^18.0.9\",\n    \"@types/react-dom\": \"^18.0.4\",\n    \"concurrently\": \"^7.5.0\",\n    \"dotenv\": \"^16.0.3\",\n    \"jest\": \"^28.1.0\",\n    \"jest-environment-jsdom\": \"^28.1.0\",\n    \"playwright\": \"^1.27.1\",\n    \"react\": \"^18.1.0\",\n    \"react-dom\": \"^18.1.0\",\n    \"rollup\": \"^2.73.0\",\n    \"rollup-plugin-dts\": \"^4.2.1\",\n    \"rollup-plugin-terser\": \"^7.0.2\",\n    \"serve\": \"^14.1.1\",\n    \"ts-jest\": \"^28.0.2\",\n    \"ts-node\": \"^10.7.0\",\n    \"typescript\": \"^5.7.3\"\n  },\n  \"overrides\": {\n    \"@types/node\": \"^22.10.10\",\n    \"yargs\": \"^17.7.2\",\n    \"yargs-parser\": \"^21.1.1\"\n  },\n  \"packageManager\": \"pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0\"\n}\n"
  },
  {
    "path": "packages/captioning/README.md",
    "content": "# @liqvid/captioning\n\nThis package provides audio transcription and captioning utilities for [Liqvid](https://liqvidjs.org). It is used internally by [@liqvid/cli](../cli).\n"
  },
  {
    "path": "packages/captioning/package.json",
    "content": "{\n  \"name\": \"@liqvid/captioning\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Audio transcription and captioning for Liqvid\",\n  \"files\": [\"dist/*\"],\n  \"main\": \"dist/index.js\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/renderer#readme\",\n  \"devDependencies\": {\n    \"ibm-watson\": \"^6.2.1\"\n  },\n  \"peerDependencies\": {\n    \"ibm-watson\": \"^6.2.1\"\n  },\n  \"peerDependenciesMeta\": {\n    \"ibm-watson\": {\n      \"optional\": true\n    }\n  }\n}\n"
  },
  {
    "path": "packages/captioning/src/index.ts",
    "content": "export {transcribe} from \"./transcription\";\nexport {toWebVTT} from \"./webvtt\";\n"
  },
  {
    "path": "packages/captioning/src/transcription.ts",
    "content": "import fs, {promises as fsp} from \"fs\";\nimport {IamAuthenticator} from \"ibm-watson/auth\";\nimport SpeechToTextV1 from \"ibm-watson/speech-to-text/v1\";\nimport path from \"path\";\nimport {toWebVTT} from \"./webvtt\";\n\n/**\n * Transcript with per-word timings\n */\nexport type Transcript = [string, number, number][][];\n\n/**\n * Transcribe audio file\n */\nexport async function transcribe(args: {\n  /** Path to audio file */\n  input: string;\n\n  /** Path for WebVTT captions */\n  captions: string;\n\n  /** Params to pass to IBM Watson. */\n  params: Partial<Parameters<SpeechToTextV1[\"recognize\"]>[0]>;\n\n  /** Path for rich transcript */\n  transcript: string;\n\n  /** IBM Cloud API key */\n  apiKey: string;\n\n  /** IBM Watson endpoint URL */\n  apiUrl: string;\n}) {\n  const filename = path.resolve(process.cwd(), args.input);\n  const output = path.resolve(process.cwd(), args.transcript);\n\n  const extn = path.extname(filename);\n\n  // SpeechToText instance\n  const speechToText = new SpeechToTextV1({\n    authenticator: new IamAuthenticator({\n      apikey: args.apiKey,\n    }),\n    serviceUrl: args.apiUrl,\n  });\n\n  const params = Object.assign(\n    {\n      audio: fs.createReadStream(filename),\n      contentType: `audio/${extn.slice(1)}`,\n\n      objectMode: true,\n      model: \"en-US_BroadbandModel\",\n      profanityFilter: false,\n      smartFormatting: true,\n      timestamps: true,\n    },\n    args.params,\n  );\n\n  // transcribe\n  const {result: json} = await speechToText.recognize(params);\n  await fsp.writeFile(args.transcript, JSON.stringify(json, null, 2));\n\n  // format\n  const blockSize = 8;\n  const words = json.results\n    .map((_) => _.alternatives[0].timestamps)\n    .reduce((a, b) => a.concat(b), [] as [string, number, number][])\n    .map(\n      ([word, t1, t2]: [string, number, number]) =>\n        [word, Math.floor(t1 * 1000), Math.floor(t2 * 1000)] as [\n          string,\n          number,\n          number,\n        ],\n    );\n\n  const blocks: Transcript = [];\n\n  for (let i = 0; i < words.length; i += blockSize) {\n    blocks.push(words.slice(i, i + blockSize));\n  }\n\n  // save new version\n  let str = JSON.stringify(blocks, null, 2);\n  str = str.replace(/(?<!\\]),\\s+/g, \", \");\n  str = str.replace(/(?<=\\[)\\s+/g, \"\");\n  str = str.replace(/(?<=\\d)\\s+(?=\\])/g, \"\");\n  await fsp.writeFile(output, str);\n\n  // make webvtt\n  const vtt = path.resolve(process.cwd(), args.captions);\n  await fsp.writeFile(vtt, toWebVTT(blocks));\n\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/captioning/src/webvtt.ts",
    "content": "import {Transcript} from \"./transcription\";\n\n/**\n * Convert rich {@link Transcript} to WebVTT string\n * @param transcript Transcript\n * @returns WebVTT file as string\n */\nexport function toWebVTT(transcript: Transcript) {\n  const captions = [\"WEBVTT\", \"\"];\n\n  for (let i = 0; i < transcript.length; ++i) {\n    const line = transcript[i];\n    if (line.length === 0) continue;\n    captions.push(String(i + 1));\n    captions.push(\n      formatTimeMs(line[0][1]) +\n        \" --> \" +\n        formatTimeMs(line[line.length - 1][2]),\n    );\n    captions.push(line.map((_) => _[0]).join(\" \"));\n    captions.push(\"\");\n  }\n\n  return captions.join(\"\\n\");\n}\n\n/* WebVTT requires mm:ss whereas @liqvid/utils/time produces [m]m:ss */\nfunction formatTime(time: number): string {\n  if (time < 0) {\n    return \"-\" + formatTime(-time);\n  }\n  const minutes = Math.floor(time / 60 / 1000),\n    seconds = Math.floor((time / 1000) % 60);\n\n  return `${minutes.toString().padStart(2, \"0\")}:${seconds.toString().padStart(2, \"0\")}`;\n}\n\nfunction formatTimeMs(time: number): string {\n  if (time < 0) {\n    return \"-\" + formatTimeMs(-time);\n  }\n  const milliseconds = Math.floor(time % 1000);\n\n  return `${formatTime(time)}.${milliseconds.toString().padStart(3, \"0\")}`;\n}\n"
  },
  {
    "path": "packages/captioning/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/cli/liqvid-cli.mjs",
    "content": "#! /usr/bin/env node\nimport * as pkg from \"./dist/index.mjs\";\n\npkg\n  .main()\n  .then(() => process.exit(0))\n  .catch((err) => {\n    // eslint-disable-next-line no-console\n    console.error(err);\n    process.exit(1);\n  });\n"
  },
  {
    "path": "packages/cli/package.json",
    "content": "{\n  \"name\": \"@liqvid/cli\",\n  \"version\": \"1.0.5\",\n  \"description\": \"Liqvid command line utility\",\n  \"main\": \"dist/index.js\",\n  \"bin\": {\n    \"liqvid\": \"liqvid-cli.mjs\"\n  },\n  \"files\": [\"dist/*\", \"liqvid-cli.mjs\"],\n  \"sideEffects\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid#readme\",\n  \"scripts\": {\n    \"lint\": \"eslint --ext mts,ts --fix src/\"\n  },\n  \"devDependencies\": {\n    \"@types/cli-progress\": \"^3.9.2\",\n    \"@types/yargs\": \"^17.0.10\"\n  },\n  \"dependencies\": {\n    \"@liqvid/captioning\": \"workspace:^\",\n    \"@liqvid/magic\": \"workspace:^\",\n    \"@liqvid/renderer\": \"workspace:^\",\n    \"@liqvid/server\": \"workspace:^\",\n    \"@liqvid/utils\": \"workspace:^\",\n    \"@types/node\": \"^17.0.23\",\n    \"cli-progress\": \"^3.10.0\",\n    \"execa\": \"^6.1.0\",\n    \"ts-node\": \"^10.7.0\",\n    \"webpack\": \"^5.70.0\",\n    \"yargs\": \"^17.4.1\",\n    \"yargs-parser\": \"^21.0.1\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/index.mts",
    "content": "import {readFile} from \"fs/promises\";\nimport * as path from \"path\";\nimport {fileURLToPath} from \"url\";\nimport yargs from \"yargs\";\nimport {hideBin} from \"yargs/helpers\";\n\n// shared options\n\nimport {audio} from \"./tasks/audio.mjs\";\nimport {build} from \"./tasks/build.mjs\";\nimport {serve} from \"./tasks/serve.mjs\";\nimport {render} from \"./tasks/render.mjs\";\nimport {thumbs} from \"./tasks/thumbs.mjs\";\n\n// entry\nexport async function main() {\n  let config = // WTF\n    yargs(hideBin(process.argv))\n      .scriptName(\"liqvid\")\n      .strict()\n      .usage(\"$0 <cmd> [args]\")\n      .demandCommand(1, \"Must specify a command\");\n\n  config = audio(config);\n  config = build(config);\n  config = serve(config);\n  config = render(config);\n  config = thumbs(config);\n\n  // version\n  const __dirname = path.dirname(fileURLToPath(import.meta.url));\n  const {version} = JSON.parse(\n    await readFile(path.join(__dirname, \"..\", \"package.json\"), \"utf8\"),\n  );\n  config.version(version);\n\n  return config.help().argv;\n}\n\nimport type {createServer} from \"@liqvid/server\";\nimport type {solidify, thumbs as captureThumbs} from \"@liqvid/renderer\";\nimport type {buildProject} from \"./tasks/build.mjs\";\nimport type {transcribe} from \"@liqvid/captioning\";\n\n/**\n * Configuration object\n */\nexport interface LiqvidConfig {\n  audio?: {\n    transcribe: Partial<Parameters<typeof transcribe>[0]>;\n  };\n  build?: Partial<Parameters<typeof buildProject>[0]>;\n  render?: Partial<Parameters<typeof solidify>[0]>;\n  serve?: Partial<Parameters<typeof createServer>[0]>;\n  thumbs?: Partial<Parameters<typeof captureThumbs>[0]>;\n}\n"
  },
  {
    "path": "packages/cli/src/tasks/audio.mts",
    "content": "import path from \"path\";\nimport type Yargs from \"yargs\";\nimport {DEFAULT_CONFIG, parseConfig} from \"./config.mjs\";\n\n/**\n * Audio utilities\n */\nexport const audio = (yargs: typeof Yargs) =>\n  yargs.command(\"audio\", \"Audio helpers\", (yargs) => {\n    return (\n      yargs\n        // convert command\n        .command(\n          \"convert <filename>\",\n          \"Repair and convert webm recordings\",\n          (yargs) =>\n            yargs.positional(\"filename\", {\n              describe: \"WebM File to convert\",\n              type: \"string\",\n            }),\n          async (opts) => {\n            const {convert} = await import(\"@liqvid/renderer/convert\");\n            await convert(opts);\n          },\n        )\n        // join command\n        .command(\n          \"join [filenames..]\",\n          \"Join audio files into a single file\",\n          (yargs) =>\n            yargs\n              .positional(\"filenames\", {\n                desc: \"Filenames to join\",\n                coerce: (filenames: string[]) =>\n                  filenames ? filenames.map((_) => path.resolve(_)) : [],\n              })\n              .option(\"output\", {\n                alias: \"o\",\n                desc: \"Output file. If not specified, defaults to last input filename.\",\n                coerce: (output?: string) =>\n                  output ? path.resolve(output) : output,\n              }),\n          async (opts) => {\n            const {join} = await import(\"@liqvid/renderer/join\");\n            await join(opts);\n          },\n        )\n        // transcribe command\n        .command(\n          \"transcribe\",\n          \"Transcribe audio\",\n          (yargs) =>\n            yargs\n              .config(\"config\", parseConfig(\"audio\", \"transcribe\"))\n              .default(\"config\", DEFAULT_CONFIG)\n              .option(\"api-key\", {\n                desc: \"IBM API key\",\n                demandOption: true,\n                type: \"string\",\n              })\n              .option(\"api-url\", {\n                desc: \"IBM Watson endpoint URL\",\n                demandOption: true,\n                type: \"string\",\n              })\n              .option(\"input\", {\n                alias: \"i\",\n                desc: \"Audio filename\",\n                normalize: true,\n                demandOption: true,\n              })\n              .option(\"captions\", {\n                alias: \"c\",\n                default: \"./captions.vtt\",\n                desc: \"Captions input filename\",\n                normalize: true,\n              })\n              .option(\"transcript\", {\n                alias: \"t\",\n                default: \"./transcript.json\",\n                desc: \"Rich transcript filename\",\n                normalize: true,\n              })\n              .option(\"params\", {\n                desc: \"Parameters for IBM Watson\",\n                default: {},\n              }),\n          async (opts) => {\n            const {transcribe} = await import(\"@liqvid/captioning\");\n            await transcribe(opts);\n          },\n        )\n        .demandCommand(1, \"Must specify an audio command\")\n    );\n  });\n"
  },
  {
    "path": "packages/cli/src/tasks/build.mts",
    "content": "import {\n  ScriptData,\n  scripts as defaultScripts,\n  StyleData,\n  styles as defaultStyles,\n  transform,\n} from \"@liqvid/magic\";\nimport {promises as fsp} from \"fs\";\nimport path from \"path\";\nimport webpack from \"webpack\";\nimport type Yargs from \"yargs\";\nimport {DEFAULT_CONFIG, parseConfig} from \"./config.mjs\";\n// @ts-expect-error TypeScript complains about this not being a module\nimport loadSync from \"./load-sync.cjs\";\n\n/**\n * Build project\n */\nexport const build = (yargs: typeof Yargs) =>\n  yargs.command(\n    \"build\",\n    \"Build project\",\n    (yargs) => {\n      return yargs\n        .config(\"config\", parseConfig(\"build\"))\n        .default(\"config\", DEFAULT_CONFIG)\n        .option(\"clean\", {\n          alias: \"C\",\n          default: false,\n          desc: \"Delete old dist directory before starting\",\n          type: \"boolean\",\n        })\n        .option(\"out\", {\n          alias: \"o\",\n          coerce: path.resolve,\n          desc: \"Output directory\",\n          default: \"./dist\",\n          normalize: true,\n        })\n        .option(\"static\", {\n          alias: \"s\",\n          coerce: path.resolve,\n          desc: \"Static directory\",\n          default: \"./static\",\n        })\n        .option(\"scripts\", {\n          coerce: coerceScripts,\n          desc: \"Script aliases\",\n          default: {},\n        })\n        .option(\"styles\", {\n          desc: \"Style aliases\",\n          default: {},\n        });\n    },\n    (args) => {\n      return buildProject(args);\n    },\n  );\n\nexport async function buildProject(config: {\n  /** Clean build directory */\n  clean: boolean;\n\n  /** Output directory */\n  out: string;\n\n  /** Static directory */\n  static: string;\n\n  scripts: Record<string, ScriptData>;\n\n  styles: Record<string, StyleData>;\n}) {\n  // clean build directory\n  if (config.clean) {\n    console.log(\"Cleaning build directory...\");\n    await fsp.rm(config.out, {force: true, recursive: true});\n  }\n\n  // ensure build directory exists\n  await fsp.mkdir(config.out, {recursive: true});\n\n  // copy static files\n  console.log(\"Copying files...\");\n  await buildStatic(config);\n\n  // webpack\n  console.log(\"Creating production bundle...\");\n  await buildBundle(config);\n}\n\n/**\n * Copy over static files.\n */\nasync function buildStatic(config: {\n  out: string;\n  static: string;\n  scripts: Record<string, ScriptData>;\n  styles: Record<string, StyleData>;\n}) {\n  const staticDir = path.resolve(process.cwd(), config.static);\n  const scripts = Object.assign({}, defaultScripts, config.scripts);\n  const styles = Object.assign({}, defaultStyles, config.styles);\n\n  await walkDir(staticDir, async (filename) => {\n    const relative = path.relative(staticDir, filename);\n    const dest = path.join(config.out, relative);\n\n    // apply html magic\n    if (filename.endsWith(\".html\")) {\n      const file = await fsp.readFile(filename, \"utf8\");\n      await idemWrite(\n        dest,\n        transform(file, {mode: \"production\", scripts, styles}),\n      );\n    } else if (relative === \"bundle.js\") {\n    } else {\n      await fsp.mkdir(path.dirname(dest), {recursive: true});\n      await fsp.copyFile(filename, dest);\n    }\n  });\n}\n\n/**\n * Compile bundle in production mode.\n */\nasync function buildBundle(config: {\n  out: string;\n}) {\n  // configure webpack\n  process.env.NODE_ENV = \"production\";\n  const webpackConfig = loadSync(path.join(process.cwd(), \"webpack.config.js\"));\n  webpackConfig.mode = \"production\";\n  webpackConfig.output.path = config.out;\n\n  const compiler = webpack(webpackConfig);\n\n  // watch\n  return new Promise<void>((resolve) => {\n    compiler.run((err, stats) => {\n      if (err) console.error(err);\n      else {\n        console.info(stats.toString({color: true}));\n      }\n      compiler.close((err, stats) => {\n        resolve();\n      });\n    });\n  });\n}\n\n/**\n * Write a file idempotently.\n */\nasync function idemWrite(filename: string, data: string) {\n  try {\n    const old = await fsp.readFile(filename, \"utf8\");\n    if (old !== data) await fsp.writeFile(filename, data);\n  } catch (e) {\n    await fsp.mkdir(path.dirname(filename), {recursive: true});\n    await fsp.writeFile(filename, data);\n  }\n}\n\n/**\n * Recursively walk a directory.\n */\nasync function walkDir(\n  dirname: string,\n  callback: (filename: string) => Promise<void>,\n) {\n  const files = (await fsp.readdir(dirname)).map((_) => path.join(dirname, _));\n  await Promise.all(\n    files.map(async (file) => {\n      const stats = await fsp.stat(file);\n      if (stats.isDirectory()) {\n        return walkDir(file, callback);\n      } else {\n        return callback(file);\n      }\n    }),\n  );\n}\n\n/**\n * Fix files.\n */\nfunction coerceScripts(\n  json: Record<\n    string,\n    | {\n        crossorigin?: boolean | string;\n        development?: string;\n        production?: string;\n      }\n    | string\n  >,\n) {\n  for (const key in json) {\n    const record = json[key];\n    if (typeof record === \"object\") {\n      if (\n        typeof record.crossorigin === \"string\" &&\n        [\"true\", \"false\"].includes(record.crossorigin)\n      ) {\n        record.crossorigin = record.crossorigin === \"true\";\n      }\n    }\n  }\n  return json;\n}\n"
  },
  {
    "path": "packages/cli/src/tasks/config.mts",
    "content": "import \"ts-node/register/transpile-only\";\nimport os from \"os\";\nimport path from \"path\";\n// @ts-expect-error TypeScript complains about this not being a module\nimport loadSync from \"./load-sync.cjs\";\n\nexport const DEFAULT_LIST = [\n  \"liqvid.config.ts\",\n  \"liqvid.config.js\",\n  \"liqvid.config.json\",\n];\nexport const DEFAULT_CONFIG = DEFAULT_LIST[0];\n\nexport function parseConfig(...keys: string[]) {\n  return (configPath: string) => {\n    try {\n      return access(loadSync(configPath), keys);\n    } catch (e) {\n      if (e.code === \"MODULE_NOT_FOUND\") {\n        // default value => assume not specified\n        if (path.join(process.cwd(), DEFAULT_CONFIG) === configPath) {\n          return {};\n        }\n        throw e;\n      } else {\n        throw e;\n      }\n    }\n  };\n}\n\n// function require(filename: string) {\n//   return JSON.parse(readFileSync(path.resolve(process.cwd(), filename), \"utf8\"));\n// }\n\nfunction access(o: any, keys: string[]): any {\n  if (keys.length === 0) return o;\n  const key = keys.shift();\n  if (!o[key]) return {};\n  return access(o[key], keys);\n}\n\nexport const BROWSER_EXECUTABLE = {\n  alias: \"x\",\n  desc: \"Path to a Chrome/ium executable. If not specified and a suitable executable cannot be found, one will be downloaded during rendering.\",\n  normalize: true,\n  type: \"string\",\n} as const;\n\nexport const CONCURRENCY = {\n  alias: \"n\",\n  default: Math.floor(os.cpus().length / 2),\n  desc: \"How many threads to use\",\n  type: \"number\",\n} as const;\n"
  },
  {
    "path": "packages/cli/src/tasks/index.mts",
    "content": "import {audio} from \"./audio.mjs\";\nimport {build} from \"./build.mjs\";\nimport {serve} from \"./serve.mjs\";\nimport {render} from \"./render.mjs\";\nimport {thumbs} from \"./thumbs.mjs\";\n\nexport const commands = [audio, build, render, serve, thumbs];\n"
  },
  {
    "path": "packages/cli/src/tasks/load-sync.cts",
    "content": "module.exports = function loadSync(path: string) {\n  return require(path);\n};\n"
  },
  {
    "path": "packages/cli/src/tasks/render.mts",
    "content": "import {parseTime} from \"@liqvid/utils/time\";\nimport type Yargs from \"yargs\";\nimport {\n  BROWSER_EXECUTABLE,\n  CONCURRENCY,\n  DEFAULT_CONFIG,\n  parseConfig,\n} from \"./config.mjs\";\n\n/** Render to static video. */\nexport const render = (yargs: typeof Yargs) =>\n  yargs.command(\n    \"render\",\n    \"Render static video\",\n    (yargs) =>\n      yargs\n        .config(\"config\", parseConfig(\"render\"))\n        .default(\"config\", DEFAULT_CONFIG)\n        .example([\n          [\"liqvid render\"],\n          [\"liqvid render -a ./audio/audio.webm -o video.webm\"],\n          [\"liqvid render -u http://localhost:8080/dist/\"],\n        ])\n        // Selection\n        .group([\"audio-file\", \"output\", \"url\"], \"What to render\")\n        .option(\"audio-file\", {\n          alias: \"a\",\n          desc: \"Path to audio file\",\n          normalize: true,\n        })\n        .option(\"output\", {\n          alias: \"o\",\n          default: \"./video.mp4\",\n          desc: \"Output filename\",\n          normalize: true,\n          demandOption: true,\n        })\n        .option(\"url\", {\n          alias: \"u\",\n          desc: \"URL of video to render\",\n          default: \"http://localhost:3000/dist/\",\n        })\n        // General configuration\n        .group(\n          [\"browser-executable\", \"concurrency\", \"config\", \"help\"],\n          \"General options\",\n        )\n        .option(\"browser-executable\", BROWSER_EXECUTABLE)\n        .option(\"concurrency\", CONCURRENCY)\n        // Input options\n        .group(\n          [\"duration\", \"end\", \"sequence\", \"start\", \"color-scheme\"],\n          \"Input options\",\n        )\n        .option(\"start\", {\n          alias: \"s\",\n          coerce: coerceTime,\n          default: \"00:00\",\n          desc: \"Start time, specify as [hh:]mm:ss[.ms]\",\n          type: \"string\",\n        })\n        .option(\"duration\", {\n          alias: \"d\",\n          conflicts: \"end\",\n          desc: \"Duration, specify as [hh:]mm:ss[.ms]\",\n          type: \"string\",\n        })\n        .coerce(\"duration\", coerceTime)\n        .option(\"end\", {\n          alias: \"e\",\n          desc: \"End time, specify as [hh:]mm:ss[.ms]\",\n          type: \"string\",\n        })\n        .coerce(\"end\", coerceTime)\n        .option(\"sequence\", {\n          alias: \"S\",\n          desc: \"Output image sequence instead of video. If this flag is set, --output will be interpreted as a directory.\",\n          type: \"boolean\",\n        })\n        .option(\"color-scheme\", {\n          default: \"light\" as \"light\" | \"dark\",\n          choices: [\"light\", \"dark\"] as const,\n          desc: \"Color scheme\",\n        })\n        // Frames\n        .group(\n          [\"height\", \"image-format\", \"quality\", \"width\"],\n          \"Frame formatting\",\n        )\n        .option(\"height\", {\n          alias: \"h\",\n          default: 800,\n          desc: \"Video height\",\n        })\n        .option(\"image-format\", {\n          alias: \"F\",\n          choices: [\"jpeg\", \"png\"] as const,\n          default: \"jpeg\" as \"jpeg\" | \"png\",\n          desc: \"Image format for frames\",\n        })\n        .option(\"quality\", {\n          alias: \"q\",\n          default: 80,\n          desc: 'Quality for images. Only applies when --image-format is \"jpeg\"',\n        })\n        .option(\"width\", {\n          alias: \"w\",\n          default: 1280,\n          desc: \"Video width\",\n        })\n        // ffmpeg\n        .group(\n          [\"audio-args\", \"fps\", \"pixel-format\", \"video-args\"],\n          \"Video options\",\n        )\n        .option(\"audio-args\", {\n          alias: \"A\",\n          desc: \"Additional flags to pass to ffmpeg, applying to the audio file\",\n          type: \"string\",\n        })\n        .option(\"fps\", {\n          alias: \"r\",\n          default: 30,\n          desc: \"Frames per second\",\n        })\n        .option(\"pixel-format\", {\n          alias: \"P\",\n          default: \"yuv420p\",\n          desc: \"Pixel format for ffmpeg\",\n        })\n        .option(\"video-args\", {\n          alias: \"V\",\n          desc: \"Additional flags to pass to ffmpeg, applying to the output video\",\n          type: \"string\",\n        })\n        .version(false),\n    async (argv) => {\n      const {solidify} = await import(\"@liqvid/renderer/solidify\");\n      await solidify(argv);\n      process.exit(0);\n    },\n  );\n\nfunction coerceTime(v: string): number {\n  if (typeof v === \"undefined\") {\n    return v;\n  }\n  try {\n    return parseTime(v);\n  } catch (e) {\n    console.error(`Invalid time: ${v}. Specify as [hh:]mm:ss[.ms]`);\n    process.exit(1);\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/tasks/serve.mts",
    "content": "import path from \"path\";\nimport type Yargs from \"yargs\";\nimport {DEFAULT_CONFIG, parseConfig} from \"./config.mjs\";\n\n/**\n * Run preview server\n */\nexport const serve = (yargs: typeof Yargs) =>\n  yargs.command(\n    \"serve\",\n    \"Run preview server\",\n    (yargs) =>\n      yargs\n        .config(\"config\", parseConfig(\"serve\"))\n        .default(\"config\", DEFAULT_CONFIG)\n        .option(\"build\", {\n          alias: \"b\",\n          coerce: path.resolve,\n          desc: \"Build directory\",\n          default: \"./dist\",\n        })\n        .option(\"livereload-port\", {\n          alias: \"L\",\n          desc: \"Port to run LiveReload on\",\n          default: 0,\n        })\n        .option(\"port\", {\n          alias: \"p\",\n          desc: \"Port to run on\",\n          default: 3000,\n        })\n        .option(\"static\", {\n          alias: \"s\",\n          coerce: path.resolve,\n          desc: \"Static directory\",\n          default: \"./static\",\n        })\n        .option(\"scripts\", {\n          desc: \"Script aliases\",\n          default: {},\n        })\n        .option(\"styles\", {\n          desc: \"Style aliases\",\n          default: {},\n        }),\n    async (argv) => {\n      const {createServer} = await import(\"@liqvid/server\");\n      // await so script doesn't close\n      await new Promise(() => {\n        createServer(argv);\n      });\n    },\n  );\n"
  },
  {
    "path": "packages/cli/src/tasks/thumbs.mts",
    "content": "import type Yargs from \"yargs\";\nimport {\n  BROWSER_EXECUTABLE,\n  CONCURRENCY,\n  DEFAULT_CONFIG,\n  parseConfig,\n} from \"./config.mjs\";\n\nexport const thumbs = (yargs: typeof Yargs) =>\n  yargs.command(\n    \"thumbs\",\n    \"Generate thumbnails\",\n    (yargs) =>\n      yargs\n        .config(\"config\", parseConfig(\"thumbs\"))\n        .default(\"config\", DEFAULT_CONFIG)\n        .example([\n          [\"liqvid thumbs\"],\n          [\n            \"liqvid thumbs -u http://localhost:8080/dist/ -o ./dist/thumbs/%s.jpeg\",\n          ],\n        ])\n        // Selection\n        .group([\"output\", \"url\"], \"What to render\")\n        .option(\"output\", {\n          alias: \"o\",\n          default: \"./thumbs/%s.jpeg\",\n          desc: \"Pattern for output filenames.\",\n          normalize: true,\n        })\n        .option(\"url\", {\n          alias: \"u\",\n          desc: \"URL of video to generate thumbs for\",\n          default: \"http://localhost:3000/dist/\",\n        })\n        // General\n        .group(\n          [\"browser-executable\", \"concurrency\", \"config\", \"help\"],\n          \"General options\",\n        )\n        .option(\"browser-executable\", BROWSER_EXECUTABLE)\n        .option(\"concurrency\", CONCURRENCY)\n        // Format\n        .group(\n          [\n            \"color-scheme\",\n            \"browser-height\",\n            \"browser-width\",\n            \"cols\",\n            \"frequency\",\n            \"height\",\n            \"image-format\",\n            \"quality\",\n            \"rows\",\n            \"width\",\n          ],\n          \"Formatting\",\n        )\n        .option(\"color-scheme\", {\n          default: \"light\" as \"light\" | \"dark\",\n          choices: [\"light\", \"dark\"] as const,\n          desc: \"Color scheme\",\n        })\n        .option(\"cols\", {\n          alias: \"c\",\n          default: 5,\n          desc: \"The number of columns per sheet\",\n        })\n        .option(\"frequency\", {\n          alias: \"f\",\n          default: 4,\n          desc: \"How many seconds between screenshots\",\n        })\n        .option(\"rows\", {\n          alias: \"r\",\n          default: 5,\n          desc: \"The number of rows per sheet\",\n        })\n        .option(\"quality\", {\n          alias: \"q\",\n          default: 80,\n          desc: 'Quality for images. Only applies when --image-format is \"jpeg\"',\n        })\n        .option(\"height\", {\n          alias: \"h\",\n          default: 100,\n          desc: \"Height of each thumbnail\",\n        })\n        .option(\"width\", {\n          alias: \"w\",\n          default: 160,\n          desc: \"Width of each thumbnail\",\n        })\n        .option(\"browser-height\", {\n          alias: \"H\",\n          desc: \"Height of screenshot before resizing\",\n          type: \"number\",\n        })\n        .option(\"browser-width\", {\n          alias: \"W\",\n          desc: \"Width of screenshot before resizing\",\n          type: \"number\",\n        })\n        .option(\"image-format\", {\n          alias: \"F\",\n          choices: [\"jpeg\", \"png\"] as const,\n          default: \"jpeg\" as \"jpeg\" | \"png\",\n          desc: \"Image format for thumbnails\",\n        })\n        .version(false),\n    async (argv) => {\n      const {thumbs: renderThumbs} = await import(\"@liqvid/renderer/thumbs\");\n      await renderThumbs(argv);\n      process.exit(0);\n    },\n  );\n"
  },
  {
    "path": "packages/cli/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"esModuleInterop\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"module\": \"esnext\",\n    \"target\": \"esnext\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/diff/CHANGELOG.md",
    "content": "## 1.1.0 (April 15, 2024)\n\nAdd generics\n\n## 1.0.0 (April 14, 2024)\n\nInitial release\n"
  },
  {
    "path": "packages/diff/README.md",
    "content": "# @liqvid/diff\n\nThis 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.\n"
  },
  {
    "path": "packages/diff/jest.config.js",
    "content": "module.exports = {\n  preset: \"ts-jest\",\n  testPathIgnorePatterns: [\"dist\"],\n  transform: {},\n};\n"
  },
  {
    "path": "packages/diff/package.json",
    "content": "{\n  \"name\": \"@liqvid/diff\",\n  \"version\": \"1.1.0\",\n  \"description\": \"Object-diffing utility\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/types/*.d.ts\"]\n    }\n  },\n  \"files\": [\"dist/*\"],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean; pnpm build:js; pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc --outDir dist/esm --module esnext; tsc --outDir dist/cjs --module commonjs; node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests\",\n    \"test\": \"eslint src --ext ts,tsx && jest --coverage\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/diff#readme\",\n  \"dependencies\": {\n    \"@liqvid/utils\": \"workspace:^\"\n  },\n  \"sideEffects\": false\n}\n"
  },
  {
    "path": "packages/diff/src/apply.ts",
    "content": "import type {ArrayDiff, ObjectDiff} from \"./types\";\nimport {matchItemDiff, matchRunes, objectKeys} from \"./utils\";\n\n/**\n * Apply a diff to an object.\n * @param a - The object to apply the diff to.\n * @param b - The diff to apply.\n * @returns A new object with the diff applied.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function applyDiff<T>(a: T, b: ObjectDiff<T>): T {\n  const copy = structuredClone(a);\n\n  for (const rkey of objectKeys(b)) {\n    matchRunes(b, rkey, {\n      create(key, item) {\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        copy[key] = item as any;\n      },\n      delete(key) {\n        delete copy[key];\n      },\n      array(key, item) {\n        const target = copy[key];\n\n        if (!Array.isArray(target)) {\n          throw new TypeError(\"Expected array\");\n        }\n\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        copy[key] = applyArrayDiff(target, item) as any;\n      },\n      object(key, item) {\n        const target = copy[key];\n\n        if (typeof target !== \"object\" || target === null) {\n          throw new TypeError(\"Expected object\");\n        }\n\n        copy[key] = applyDiff(target, item);\n      },\n      change(key, item) {\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        copy[key] = item as any;\n      },\n    });\n  }\n\n  return copy;\n}\n\n/**\n * Apply a diff to an array.\n * @param arr - The array to apply the diff to.\n * @param diff - The diff to apply.\n * @returns A new array with the diff applied.\n */\nexport function applyArrayDiff<T>(arr: T[], diff: ArrayDiff<T>): T[] {\n  const [delta, itemDiffs = [], ...appends] = diff;\n  const copy = arr.slice();\n\n  for (const diff of itemDiffs) {\n    matchItemDiff(diff, {\n      set(offset, item) {\n        copy[copy.length - offset] = item as T;\n      },\n      array(offset, item) {\n        copy[copy.length - offset] = applyArrayDiff(\n          copy[copy.length - offset] as unknown[],\n          item,\n        ) as T;\n      },\n      object(offset, item) {\n        copy[copy.length - offset] = applyDiff(\n          copy[copy.length - offset],\n          item,\n        ) as T;\n      },\n    });\n  }\n\n  if (delta < 0) {\n    copy.splice(copy.length + delta, -delta);\n  } else {\n    for (const append of appends) {\n      copy.push(append as T);\n    }\n  }\n\n  return copy;\n}\n"
  },
  {
    "path": "packages/diff/src/builders.ts",
    "content": "import {deletePlaceholder, runes} from \"./runes\";\nimport type {\n  ArrayDiff,\n  ArrayItemDiff,\n  ChangeItemDiff,\n  DeletePlaceholder,\n  ObjectDiff,\n  RunedKey,\n} from \"./types\";\n\n/**\n * Make a diff to create a value.\n * @param key Key to use.\n * @param value Value to create.\n */\nexport function creationDiff<K extends string, V>(key: string, value: V) {\n  return {[`${runes.create}${key}`]: value} as Record<RunedKey<\"create\", K>, V>;\n}\n\n/**\n * Make a diff to delete a value.\n * @param key Key to use.\n * @returns Diff to delete the value.\n */\nexport function deletionDiff<K extends string>(key: K) {\n  return {[`${runes.delete}${key}`]: deletePlaceholder} as Record<\n    RunedKey<\"delete\", K>,\n    DeletePlaceholder\n  >;\n}\n\n/**\n * Make a diff to update an array.\n * @param key Key to use.\n * @param diff Array diff to apply.\n */\nexport function arrayDiff<K extends string, T, D extends ArrayDiff<T>>(\n  key: K,\n  diff: D,\n) {\n  return {[`${runes.array}${key}`]: diff} as Record<RunedKey<\"array\", K>, D>;\n}\n\n/**\n * Make a diff to update an object.\n * @param key Key to use.\n * @param diff Object diff to apply.\n */\nexport function objectDiff<K extends string, T, D extends ObjectDiff<T>>(\n  key: K,\n  diff: D,\n) {\n  return {[`${runes.object}${key}`]: diff} as Record<RunedKey<\"object\", K>, D>;\n}\n\n/**\n * Make a diff to set a value.\n * @param key Key to use.\n * @value Value to set.\n */\nexport function changeDiff<K extends string, V>(key: string, value: V) {\n  return {[`${runes.change}${key}`]: value} as Record<RunedKey<\"change\", K>, V>;\n}\n\n// item diffs\n\n/**\n * Make an item diff to change a value.\n * @param offset Offset from the end to change.\n * @param value Value to change to.\n */\nexport function changeItemDiff<T>(offset: number, value: T): ChangeItemDiff<T> {\n  return [offset, value];\n}\n\n/**\n * Make an item diff to change an array item.\n * @param offset Offset from the end to change.\n * @param diff Array diff to apply.\n */\nexport function arrayItemDiff<T>(\n  offset: number,\n  diff: ArrayDiff<T>,\n): ArrayItemDiff<T> {\n  return [`${runes.array}${offset}`, diff];\n}\n\n/**\n * Make an item diff to change an object item.\n * @param index Index to change.\n * @param diff Object diff to apply.\n */\nexport function objectItemDiff<T, D extends ObjectDiff<T>>(\n  offset: number,\n  diff: D,\n) {\n  return [`${runes.object}${offset}`, diff] as [RunedKey<\"object\">, D];\n}\n"
  },
  {
    "path": "packages/diff/src/compute.ts",
    "content": "import {assertType} from \"@liqvid/utils/types\";\nimport {\n  arrayDiff,\n  arrayItemDiff,\n  changeDiff,\n  creationDiff,\n  deletionDiff,\n  objectDiff,\n  objectItemDiff,\n} from \"./builders\";\nimport type {ArrayDiff, ObjectDiff} from \"./types\";\nimport {cmp} from \"./utils\";\n\n/** Compute the diff between two arrays. */\nexport function diffArrays<T>(a: T[], b: T[]): ArrayDiff<T> {\n  // diffs\n  const itemDiffs: Exclude<ArrayDiff<T>[1], undefined> = [];\n\n  for (let i = 0; i < Math.min(a.length, b.length); ++i) {\n    const itemA = a[i];\n    const itemB = b[i];\n\n    const offset = a.length - i;\n\n    if (!cmp(itemA, itemB)) {\n      // simple replace\n      if (\n        typeof itemA !== typeof itemB ||\n        typeof itemA === \"bigint\" ||\n        typeof itemA === \"boolean\" ||\n        typeof itemA === \"number\" ||\n        typeof itemA === \"string\" ||\n        itemA === null ||\n        itemB === null ||\n        Array.isArray(itemA) != Array.isArray(itemB)\n      ) {\n        itemDiffs.push([offset, itemB]);\n        continue;\n      }\n\n      if (Array.isArray(itemA)) {\n        assertType<unknown[]>(itemB);\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        itemDiffs.push(arrayItemDiff<any>(offset, diffArrays(itemA, itemB)));\n      } else {\n        assertType<Record<string, unknown>>(itemA);\n        assertType<Record<string, unknown>>(itemB);\n        itemDiffs.push(objectItemDiff(offset, diffObjects(itemA, itemB)));\n      }\n    }\n  }\n\n  const delta = b.length - a.length;\n\n  // pure deletion\n  if (itemDiffs.length === 0 && delta <= 0) {\n    return [delta];\n  } else {\n    return [b.length - a.length, itemDiffs, ...b.slice(a.length)];\n  }\n}\n\n/**\n * Compute the diff between two objects.\n */\nexport function diffObjects<T>(a: T, b: T): ObjectDiff<T> {\n  assertType<Record<string, unknown>>(a);\n  assertType<Record<string, unknown>>(b);\n\n  const ret: ObjectDiff<T> = {};\n\n  const keysA = Object.keys(a);\n  const keysB = new Set(Object.keys(b));\n\n  for (const key of keysA) {\n    if (!keysB.has(key)) {\n      Object.assign(ret, deletionDiff(key));\n      continue;\n    }\n\n    const valueA = a[key] as unknown;\n    const valueB = b[key] as unknown;\n\n    if (typeof valueA !== typeof valueB) {\n      console.warn(\"Expected same type\");\n    } else {\n      if (!cmp(valueA, valueB)) {\n        switch (typeof valueA) {\n          case \"string\":\n          case \"number\":\n          case \"boolean\":\n          case \"bigint\":\n            Object.assign(ret, changeDiff(key, valueB));\n            break;\n          case \"object\":\n            if (\n              valueA === null ||\n              valueB === null ||\n              Array.isArray(valueA) !== Array.isArray(valueB)\n            ) {\n              Object.assign(ret, changeDiff(key, valueB));\n            } else if (Array.isArray(valueA)) {\n              assertType<unknown[]>(valueB);\n              Object.assign(ret, arrayDiff(key, diffArrays(valueA, valueB)));\n            } else {\n              assertType<object>(valueB);\n              Object.assign(ret, objectDiff(key, diffObjects(valueA, valueB)));\n            }\n            break;\n        }\n      }\n    }\n\n    keysB.delete(key);\n  }\n\n  for (const key of keysB) {\n    Object.assign(ret, creationDiff(key, b[key]));\n  }\n\n  return ret;\n}\n"
  },
  {
    "path": "packages/diff/src/index.ts",
    "content": "export {applyArrayDiff, applyDiff} from \"./apply\";\nexport {\n  arrayDiff,\n  arrayItemDiff,\n  changeDiff,\n  changeItemDiff,\n  creationDiff,\n  deletionDiff,\n  objectDiff,\n  objectItemDiff,\n} from \"./builders\";\nexport {diffArrays, diffObjects as diffObjects} from \"./compute\";\nexport {mergeArrayDiffs, mergeDiffs} from \"./merge\";\nexport type {\n  ArrayDiff,\n  ArrayItemDiff,\n  ChangeItemDiff,\n  DeletePlaceholder,\n  ItemDiff,\n  ObjectDiff,\n  ObjectItemDiff,\n  Rune,\n  RuneName,\n  RunedKey,\n} from \"./types\";\nexport {cmp, invertDiff, matchItemDiff, matchRunes} from \"./utils\";\n"
  },
  {
    "path": "packages/diff/src/merge.ts",
    "content": "import {assertDefined, assertType} from \"@liqvid/utils/types\";\nimport {applyArrayDiff, applyDiff} from \"./apply\";\nimport {\n  arrayDiff,\n  arrayItemDiff,\n  changeDiff,\n  changeItemDiff,\n  creationDiff,\n  deletionDiff,\n  objectDiff,\n  objectItemDiff,\n} from \"./builders\";\nimport type {ArrayDiff, ItemDiff, ObjectDiff} from \"./types\";\nimport {\n  consume,\n  getOffset,\n  matchItemDiff,\n  matchRunes,\n  objectKeys,\n} from \"./utils\";\n\n/** Merge two array diffs. */\nexport function mergeArrayDiffs<T>(\n  a: ArrayDiff<T>,\n  b: ArrayDiff<T>,\n): ArrayDiff<T> {\n  const [deltaA, itemDiffsA = [], ...tailA] = a;\n  const [deltaB, itemDiffsB = [], ...tailB] = b;\n\n  const delta = deltaA + deltaB;\n\n  // combine item diffs\n  const itemDiffs: ItemDiff<T>[] = [];\n\n  let iterA = 0;\n  let iterB = 0;\n\n  for (; iterA < itemDiffsA.length || iterB < itemDiffsB.length; ) {\n    const itemA = itemDiffsA.at(iterA);\n    const itemB = itemDiffsB.at(iterB);\n\n    const offsetA = itemA ? getOffset(itemA[0]) : 0;\n    const offsetB = itemB ? getOffset(itemB[0]) : 0;\n\n    const newOffsetB = offsetB - deltaA;\n\n    if (offsetA > newOffsetB) {\n      assertDefined(itemA);\n      // skip if deleted by b\n      if (offsetA > -deltaB) {\n        itemDiffs.push(itemA);\n      }\n      iterA++;\n    } else if (newOffsetB > offsetA) {\n      assertDefined(itemB);\n      if (deltaA >= 0) {\n        // adjust the tail of A\n        if (offsetB <= tailA.length) {\n          const tailOffset = tailA.length - offsetB;\n          const valueA = tailA[tailOffset];\n\n          matchItemDiff(itemB, {\n            // set\n            set(_, valueB) {\n              tailA[tailOffset] = valueB;\n            },\n            // array\n            array(_, valueB) {\n              assertType<unknown[]>(valueA);\n              tailA[tailOffset] = applyArrayDiff(valueA, valueB);\n            },\n            object(_, valueB) {\n              assertType<Record<string, unknown>>(valueA);\n              tailA[tailOffset] = applyDiff(valueA, valueB);\n            },\n          });\n        } else {\n          itemDiffs.push([newOffsetB, itemB[1]] as ItemDiff<T>);\n        }\n      } else {\n        itemDiffs.push([newOffsetB, itemB[1]] as ItemDiff<T>);\n      }\n\n      iterB++;\n    } else {\n      assertDefined(itemA);\n      assertDefined(itemB);\n      // offsetA === newOffsetB\n      matchItemDiff(itemA, {\n        set(_, valueA) {\n          matchItemDiff(itemB, {\n            // change(a) * change(b) = change(b)\n            set(_, valueB) {\n              // eslint-disable-next-line @typescript-eslint/no-explicit-any\n              itemDiffs.push(changeItemDiff<any>(offsetA, valueB));\n            },\n            // change(a) * array(b) = change(a*b)\n            array(_, valueB) {\n              assertType<unknown[]>(valueA);\n              itemDiffs.push(\n                // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                changeItemDiff<any>(offsetA, applyArrayDiff(valueA, valueB)),\n              );\n            },\n            // change(a) * object(b) = change(a*b)\n            object(_, valueB) {\n              assertType<object>(valueA);\n              itemDiffs.push(\n                // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                changeItemDiff<any>(offsetA, applyDiff(valueA, valueB)),\n              );\n            },\n          });\n        },\n        array(_, valueA) {\n          matchItemDiff(itemB, {\n            // array(a) * change(b) = change(b)\n            set(_, valueB) {\n              // eslint-disable-next-line @typescript-eslint/no-explicit-any\n              itemDiffs.push(changeItemDiff<any>(offsetA, valueB));\n            },\n            // array(a) * array(b) = array(a*b)\n            array(_, valueB) {\n              itemDiffs.push(\n                // eslint-disable-next-line @typescript-eslint/no-explicit-any\n                arrayItemDiff<any>(offsetA, mergeArrayDiffs(valueA, valueB)),\n              );\n            },\n          });\n        },\n        object(_, valueA) {\n          matchItemDiff(itemB, {\n            // object(a) * change(b) = change(b)\n            set(_, valueB) {\n              // eslint-disable-next-line @typescript-eslint/no-explicit-any\n              itemDiffs.push(changeItemDiff<any>(offsetA, valueB));\n            },\n            // object(a) * object(b) = object(a*b)\n            object(_, valueB) {\n              itemDiffs.push(\n                objectItemDiff(offsetA, mergeDiffs(valueA, valueB)),\n              );\n            },\n          });\n        },\n      });\n\n      iterA++;\n      iterB++;\n    }\n  }\n\n  // needs to come afterwards since we modify tailA above\n  const tail = [\n    ...tailA.slice(0, tailA.length + Math.min(0, deltaB)),\n    ...tailB,\n  ];\n\n  return [delta, itemDiffs, ...tail];\n}\n\n/** Merge two object diffs. */\nexport function mergeDiffs<T>(\n  a: ObjectDiff<T>,\n  b: ObjectDiff<T>,\n): ObjectDiff<T> {\n  const ret: ObjectDiff<T> = {};\n\n  for (const rKeyB of objectKeys(b)) {\n    matchRunes(b, rKeyB, {\n      // create\n      create(key, valueB) {\n        consume(a, key, {\n          // delete * create(b) = set(b)\n          delete() {\n            Object.assign(ret, changeDiff(key, valueB));\n          },\n          none() {\n            Object.assign(ret, creationDiff(key, valueB));\n          },\n          else(name) {\n            throw new Error(`Invalid merge: ${name}-add`);\n          },\n        });\n      },\n      // delete\n      delete(key) {\n        consume(a, key, {\n          delete() {\n            throw new Error(\"Invalid merge: delete-delete\");\n          },\n        });\n        Object.assign(ret, deletionDiff(key));\n      },\n      // set\n      change(key, valueB) {\n        consume(a, key, {\n          // create * set(b) = create(b)\n          create() {\n            Object.assign(ret, creationDiff(key, valueB));\n          },\n          // invalid\n          delete() {\n            throw new Error(\"Invalid merge: delete-set\");\n          },\n          // _ * set(b) = set(b)\n          else() {\n            Object.assign(ret, changeDiff(key, valueB));\n          },\n          none() {\n            Object.assign(ret, changeDiff(key, valueB));\n          },\n        });\n      },\n      // array\n      array(key, valueB) {\n        consume(a, key, {\n          // create(a) * array(b) = create(a*b)\n          create(valueA) {\n            assertType<unknown[]>(valueA);\n            Object.assign(\n              ret,\n              creationDiff(key, applyArrayDiff(valueA, valueB)),\n            );\n          },\n          // set(a) * array(b) = set(a*b)\n          change(valueA) {\n            assertType<unknown[]>(valueA);\n            Object.assign(ret, changeDiff(key, applyArrayDiff(valueA, valueB)));\n          },\n          else(name) {\n            throw new Error(`Invalid merge: ${name}-array`);\n          },\n          array(valueA) {\n            Object.assign(ret, arrayDiff(key, mergeArrayDiffs(valueA, valueB)));\n          },\n          none() {\n            Object.assign(ret, arrayDiff(key, valueB));\n          },\n        });\n      },\n      // object\n      object(key, valueB) {\n        consume(a, key, {\n          // create(a) * object(b) = object(a*b)\n          create(valueA) {\n            assertType<object>(valueA);\n            Object.assign(ret, creationDiff(key, applyDiff(valueA, valueB)));\n          },\n          // set(a) * object(b) = set(a*b)\n          change(valueA) {\n            assertType<object>(valueA);\n            Object.assign(ret, changeDiff(key, applyDiff(valueA, valueB)));\n          },\n          else(name) {\n            throw new Error(`Invalid merge: ${name}-array`);\n          },\n          // object(a) * object(b) = object(a*b)\n          object(valueA) {\n            Object.assign(ret, objectDiff(key, mergeDiffs(valueA, valueB)));\n          },\n          none() {\n            Object.assign(ret, objectDiff(key, valueB));\n          },\n        });\n      },\n    });\n  }\n\n  // add anything remaining from a\n  Object.assign(ret, a);\n\n  return ret;\n}\n"
  },
  {
    "path": "packages/diff/src/runes.ts",
    "content": "export const runes = {\n  array: \"#\",\n  change: \"=\",\n  create: \"+\",\n  delete: \"-\",\n  object: \"@\",\n} as const;\n\nexport const deletePlaceholder = 0;\n"
  },
  {
    "path": "packages/diff/src/types.ts",
    "content": "import type {deletePlaceholder, runes} from \"./runes\";\n\n// runes\nexport type RuneName = keyof typeof runes;\nexport type Rune = (typeof runes)[RuneName];\nexport type RunedKey<\n  K extends RuneName,\n  Name extends string = string,\n> = `${(typeof runes)[K]}${Name}`;\n\n// array diffs\nexport type ChangeItemDiff<T> = [offset: number, value: T];\nexport type ObjectItemDiff<T> = [\n  offset: RunedKey<\"object\">,\n  diff: ObjectDiff<T>,\n];\nexport type ArrayItemDiff<T> = [offset: RunedKey<\"array\">, diff: ArrayDiff<T>];\n\n/**\n * Note that offsets are relative to the **end** of the array.\n */\nexport type ItemDiff<T> =\n  | ArrayItemDiff<T>\n  | ChangeItemDiff<T>\n  | ObjectItemDiff<T>;\n\n/**\n * A record describing how to make changes to an array.\n */\nexport type ArrayDiff<T> = [\n  delta: number,\n  itemDiffs?: ItemDiff<T>[],\n  ...tail: unknown[],\n];\n\n// delete placeholder\nexport type DeletePlaceholder = typeof deletePlaceholder;\n\n/**\n * A record describing how to make changes to an object.\n */\nexport type ObjectDiff<T> = {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  [key: RunedKey<\"array\">]: ArrayDiff<any>;\n  [key: RunedKey<\"change\">]: unknown;\n  [key: RunedKey<\"create\">]: unknown;\n  [key: RunedKey<\"delete\">]: DeletePlaceholder;\n  [key: RunedKey<\"object\">]: ObjectDiff<T>;\n};\n"
  },
  {
    "path": "packages/diff/src/utils.ts",
    "content": "import {assertType} from \"@liqvid/utils/types\";\nimport {applyDiff} from \"./apply\";\nimport {diffObjects} from \"./compute\";\nimport {runes} from \"./runes\";\nimport type {\n  ArrayDiff,\n  ItemDiff,\n  ObjectDiff,\n  Rune,\n  RuneName,\n  RunedKey,\n} from \"./types\";\n\n/** Typed {@link Object.keys} */\nexport function objectKeys<T extends object>(obj: T): (keyof T)[] {\n  return Object.keys(obj) as (keyof T)[];\n}\n\n/** Comparison function */\nexport function cmp(a: unknown, b: unknown): boolean {\n  if (typeof a !== typeof b) return false;\n\n  switch (typeof a) {\n    case \"bigint\":\n    case \"boolean\":\n    case \"function\":\n    case \"number\":\n    case \"string\":\n    case \"symbol\":\n    case \"undefined\":\n      return a === b;\n  }\n\n  if (a === null || b === null) return a === b;\n\n  assertType<Record<string, unknown>>(a);\n  assertType<Record<string, unknown>>(b);\n\n  const keysA = Object.keys(a);\n  const keysB = new Set(Object.keys(b));\n\n  if (keysA.length !== keysB.size) return false;\n  return keysA.every((key) => keysB.has(key) && cmp(a[key], b[key]));\n}\n\n/**\n * Pattern-match on an item diff.\n */\nexport function matchItemDiff<T, R>(\n  [offset, item]: ItemDiff<T>,\n  fns: {\n    set?: (offset: number, value: unknown) => R;\n    array?: (offset: number, value: ArrayDiff<T[number & keyof T]>) => R;\n    object?: (offset: number, value: ObjectDiff<T[string & keyof T]>) => R;\n  },\n): R | undefined {\n  if (typeof offset === \"number\") {\n    return fns.set?.(offset, item);\n  }\n\n  const numeric = getOffset(offset);\n\n  if (isRune(offset, runes.array)) {\n    assertType<ArrayDiff<T[number & keyof T]>>(item);\n    return fns?.array?.(numeric, item);\n  } else if (isRune(offset, runes.object)) {\n    assertType<ObjectDiff<T[string & keyof T]>>(item);\n    return fns?.object?.(numeric, item);\n  }\n}\n\nexport function isRune<R extends Rune>(\n  key: string,\n  rune: R,\n): key is `${R}${string}` {\n  return key.startsWith(rune as string);\n}\n\n/** Pattern-match on an object diff. */\nexport function matchRunes<T, R>(\n  diff: ObjectDiff<T>,\n  key: keyof ObjectDiff<T>,\n  fns: {\n    [name in RuneName]?: (\n      key: string & keyof T,\n      rkey: ObjectDiff<T>[RunedKey<name>],\n    ) => R;\n  },\n): R | undefined {\n  for (const name of Object.keys(fns) as RuneName[]) {\n    const rune = runes[name];\n    if (key.startsWith(rune)) {\n      const fn = fns[name];\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      return fn(key.slice(rune.length) as any, diff[key] as any);\n    }\n  }\n}\n\nexport function consume<T>(\n  a: ObjectDiff<T>,\n  key: string,\n  fns: {\n    [$name in RuneName | \"else\" | \"none\"]?: $name extends RuneName\n      ? (value: ObjectDiff<T>[RunedKey<$name>]) => unknown\n      : $name extends \"else\"\n        ? <K extends RuneName>(\n            name: K,\n            value: ObjectDiff<T>[RunedKey<K>],\n          ) => unknown\n        : () => unknown;\n  } = {},\n) {\n  for (const name of objectKeys(runes)) {\n    const rune = runes[name];\n    const keyA = `${rune}${key}` as const;\n\n    if (!(keyA in a)) continue;\n\n    const valueA = a[keyA];\n    delete a[keyA];\n\n    const fn = fns[name];\n    if (fn) {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      return fn(valueA as any);\n    }\n\n    if (fns.else) {\n      return fns.else(name, valueA);\n    }\n  }\n\n  // if nothing matched\n  return fns.none?.();\n}\n\nexport function getOffset(offset: ItemDiff<unknown>[0]): number {\n  if (typeof offset === \"number\") return offset;\n\n  if (isRune(offset, runes.array)) {\n    return parseInt(offset.slice(runes.array.length), 10);\n  } else if (isRune(offset, runes.object)) {\n    return parseInt(offset.slice(runes.object.length), 10);\n  }\n\n  throw new Error(`Invalid index: ${offset}`);\n}\n\nexport function addToOffset<O extends ItemDiff<unknown>[0]>(\n  offset: O,\n  delta: number,\n): O {\n  const result = getOffset(offset) + delta;\n  if (typeof offset === \"number\") return result as O;\n\n  if (isRune(offset, runes.array)) {\n    return `${runes.array}${result}` as O;\n  }\n\n  return `${runes.object}${result}` as O;\n}\n\n/**\n * Invert a diff with respect to an object.\n * Note that it is not possible to invert a lone diff.\n */\nexport function invertDiff<T>(state: T, diff: ObjectDiff<T>): ObjectDiff<T> {\n  return diffObjects(applyDiff(state, diff), state);\n}\n"
  },
  {
    "path": "packages/diff/tests/suite.test.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {applyDiff, diffObjects} from \"../src\";\n\ndescribe(\"diffObjects and applyDiff\", () => {\n  test(\"property deletion\", () => {\n    const a = {x: 1};\n    const b = {};\n\n    const diff = diffObjects(a, b);\n\n    expect(diff).toEqual({\"-x\": 0});\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"property additions\", () => {\n    const a = {};\n    const b = {x: 1};\n\n    const diff = diffObjects(a, b);\n\n    expect(diff).toEqual({\"+x\": 1});\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"property changes\", () => {\n    const a = {x: 1};\n    const b = {x: 2};\n\n    const diff = diffObjects(a, b);\n\n    expect(diff).toEqual({\"=x\": 2});\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"array appends\", () => {\n    const a = {x: [0, 1, 2]};\n    const b = {x: [0, 1, 2, 3, 4]};\n\n    const diff = diffObjects(a, b);\n\n    expect(diff).toEqual({\"#x\": [2, [], 3, 4]});\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"array deletions\", () => {\n    const a = {x: [0, 1, 2]};\n    const b = {x: [0]};\n\n    const diff = diffObjects(a, b);\n\n    expect(diff).toEqual({\"#x\": [-2]});\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"array changes\", () => {\n    const a = {x: [0, 1, 2]};\n    const b = {x: [0, 3]};\n\n    const diff = diffObjects(a, b);\n\n    expect(diff).toEqual({\"#x\": [-1, [[2, 3]]]});\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"nested objects\", () => {\n    const a = {x: {fruit: \"apple\", color: \"red\"}};\n    const b = {x: {fruit: \"potato\", kind: \"mashed\"}};\n\n    const diff = diffObjects<any>(a, b);\n\n    expect(diff).toEqual({\n      \"@x\": {\"=fruit\": \"potato\", \"+kind\": \"mashed\", \"-color\": 0},\n    });\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"objects nested in arrays\", () => {\n    const a = {\n      shapes: {\n        square: {\n          segments: [{type: \"free\", points: [0, 1]}],\n        },\n      },\n    };\n    const b = {\n      shapes: {\n        square: {\n          segments: [{type: \"free\", points: [0, 1, 2, 3]}],\n        },\n      },\n    };\n\n    const diff = diffObjects(a, b);\n    expect(diff).toEqual({\n      \"@shapes\": {\n        \"@square\": {\n          \"#segments\": [\n            0,\n            [\n              [\n                \"@1\",\n                {\n                  \"#points\": [2, [], 2, 3],\n                },\n              ],\n            ],\n          ],\n        },\n      },\n    });\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n\n  test(\"kitchen sink\", () => {\n    const a = {\n      x: 1,\n      y: 2,\n      z: 3,\n      arr: [1, 2, 3],\n      obj: {fruit: \"apple\", color: \"red\"},\n    };\n    const b = {\n      x: 3,\n      z: 3,\n      w: 4,\n      arr: [1, 5, 4, \"x\", \"y\"],\n      obj: {fruit: \"potato\", kind: \"mashed\"},\n    };\n\n    const diff = diffObjects<any>(a, b);\n\n    expect(diff).toEqual({\n      \"=x\": 3,\n      \"-y\": 0,\n      \"+w\": 4,\n      \"#arr\": [\n        2,\n        [\n          [2, 5],\n          [1, 4],\n        ],\n        \"x\",\n        \"y\",\n      ],\n      \"@obj\": {\n        \"=fruit\": \"potato\",\n        \"+kind\": \"mashed\",\n        \"-color\": 0,\n      },\n    });\n    expect(applyDiff(a, diff)).toEqual(b);\n  });\n});\n"
  },
  {
    "path": "packages/diff/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/duration/README.md",
    "content": "# @liqvid/duration\n\nProvides an opaque `Duration` type to handle relative times, avoiding millisecond/second mismatches.\n"
  },
  {
    "path": "packages/duration/package.json",
    "content": "{\n  \"name\": \"@liqvid/duration\",\n  \"version\": \"1.1.0\",\n  \"description\": \"Class for unitless time intervals\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\n        \"./dist/types/*.d.ts\"\n      ]\n    }\n  },\n  \"files\": [\n    \"dist/*\"\n  ],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean; pnpm build:js; pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"pnpm build:js:cjs; pnpm build:js:esm; pnpm build:js:fix\",\n    \"build:js:cjs\": \"tsc --module commonjs --outDir dist/cjs\",\n    \"build:js:esm\": \"tsc --module esnext --outDir dist/esm\",\n    \"build:js:fix\": \"node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"biome check --fix\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/duration#readme\",\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"catalog:\",\n    \"typescript\": \"catalog:\"\n  },\n  \"sideEffects\": false\n}\n"
  },
  {
    "path": "packages/duration/src/index.ts",
    "content": "const SECONDS = 1000,\n  MINUTES = 60 * SECONDS,\n  HOURS = 60 * MINUTES,\n  DAYS = 24 * HOURS,\n  WEEKS = 7 * DAYS;\n\n/**\n * Convenience type representing either a {@link Duration}\n * or creation options for one\n */\nexport type DurationLike = Duration | DurationOptions;\n\n/**\n * These are additive, e.g. passing `{seconds: 20, minutes: 5}` is\n * equivalent to passing `{seconds: 320}`.\n */\nexport interface DurationOptions {\n  milliseconds?: number;\n  seconds?: number;\n  minutes?: number;\n  hours?: number;\n  days?: number;\n  weeks?: number;\n}\n\n/**\n * Interval between two points in time, agnostic of units.\n */\nexport class Duration {\n  private __valueMs: number;\n\n  constructor({\n    milliseconds = 0,\n    seconds = 0,\n    minutes = 0,\n    hours = 0,\n    days = 0,\n    weeks = 0,\n  }: DurationOptions = {}) {\n    this.__valueMs =\n      weeks * WEEKS +\n      days * DAYS +\n      hours * HOURS +\n      minutes * MINUTES +\n      seconds * SECONDS +\n      milliseconds;\n  }\n\n  /**\n   * Coerce a DurationLike into a Duration\n   */\n  static from(val: DurationLike): Duration {\n    if (val instanceof Duration) return val;\n    return new Duration(val);\n  }\n\n  /**\n   * Create a new {@link Duration} object and receive a callback\n   * to imperatively set its value. This is useful when you need\n   * to keep a {@link Duration} object in sync with some changing\n   * value (e.g. wrapping `currentTime` on a `<video>` element),\n   * and want to avoid allocating lots of new objects. Keeping the\n   * setter separate ensures that consumers of your wrapped value\n   * cannot change it.\n   */\n  static withSetter(\n    options?: DurationOptions,\n  ): [Duration, { setMilliseconds: (ms: number) => void }] {\n    const d = new Duration(options);\n    const setMilliseconds = (ms: number) => {\n      d.__valueMs = ms;\n    };\n    return [d, { setMilliseconds }];\n  }\n\n  /* extractors */\n  inDays(): number {\n    return this.__valueMs / DAYS;\n  }\n\n  inHours(): number {\n    return this.__valueMs / HOURS;\n  }\n\n  inMilliseconds(): number {\n    return this.__valueMs;\n  }\n\n  inMinutes(): number {\n    return this.__valueMs / MINUTES;\n  }\n\n  inSeconds(): number {\n    return this.__valueMs / SECONDS;\n  }\n\n  inWeeks(): number {\n    return this.__valueMs / WEEKS;\n  }\n\n  /* comparison */\n  /** lower <= this < upper */\n  between(lower: DurationLike, upper: DurationLike): boolean {\n    return this.greaterThanOrEqual(lower) && this.lessThan(upper);\n  }\n\n  equals(other: DurationLike): boolean {\n    other = Duration.from(other);\n    return this.__valueMs === other.__valueMs;\n  }\n\n  greaterThan(other: DurationLike): boolean {\n    return !this.lessThanOrEqual(other);\n  }\n\n  greaterThanOrEqual(other: DurationLike): boolean {\n    return !this.lessThan(other);\n  }\n\n  lessThan(other: DurationLike): boolean {\n    other = Duration.from(other);\n    return this.__valueMs < other.__valueMs;\n  }\n\n  lessThanOrEqual(other: DurationLike): boolean {\n    other = Duration.from(other);\n    return this.__valueMs <= other.__valueMs;\n  }\n\n  /* arithmetic */\n  dividedBy(other: DurationLike): number {\n    other = Duration.from(other);\n    return this.__valueMs / other.__valueMs;\n  }\n\n  minus(other: DurationLike): Duration {\n    other = Duration.from(other);\n    return new Duration({\n      milliseconds: this.__valueMs - other.__valueMs,\n    });\n  }\n\n  plus(other: DurationLike): Duration {\n    other = Duration.from(other);\n    return new Duration({\n      milliseconds: this.__valueMs + other.__valueMs,\n    });\n  }\n\n  times(factor: number): Duration {\n    return new Duration({ milliseconds: this.__valueMs * factor });\n  }\n}\n"
  },
  {
    "path": "packages/duration/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"extends\": \"../../tsconfig.json\",\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/gsap/README.md",
    "content": "# @liqvid/gsap\n\nThis module provides [GSAP](https://greensock.com/gsap/) integration for Liqvid.\n\n## Installation\n\n    $ npm install @liqvid/gsap\n\n## Usage\n\nSee the [GSAP docs](https://greensock.com/docs/) and especially the [React section](https://greensock.com/react).\n\n```tsx\nimport {useTimeline} from \"@liqvid/gsap\";\nimport {useEffect} from \"react\";\n\nexport function Demo() {\n  const tl = useTimeline();\n  \n  useEffect(() => {\n    tl.to(\".box\", {duration: 3, x: 800});\n    tl.to(\".box\", {duration: 3, rotation: 360, y: 500});\n    tl.to(\".box\", {duration: 3, x: 0});\n  }, []);\n  \n  return (\n    <section>\n      <div className=\"box orange\"></div>\n      <div className=\"box grey\"></div>\n      <div className=\"box green\"></div>\n    </section>\n  );\n}\n```\n"
  },
  {
    "path": "packages/gsap/package.json",
    "content": "{\n  \"name\": \"@liqvid/gsap\",\n  \"version\": \"1.0.1\",\n  \"description\": \"GSAP bindings for Liqvid\",\n  \"keywords\": [\"animation\", \"GSAP\", \"Liqvid\"],\n  \"main\": \"./dist/index.js\",\n  \"typings\": \"./dist/index.d.ts\",\n  \"files\": [\"dist/*\"],\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid#readme\",\n  \"peerDependencies\": {\n    \"gsap\": \"^3.9.0\",\n    \"liqvid\": \"workspace:^\"\n  },\n  \"sideEffects\": false,\n  \"devDependencies\": {\n    \"gsap\": \"^3.9.1\"\n  }\n}\n"
  },
  {
    "path": "packages/gsap/src/index.ts",
    "content": "import gsap from \"gsap\";\nimport {Playback, usePlayer} from \"liqvid\";\n\nconst sym = Symbol();\n\ndeclare module \"liqvid\" {\n  interface Playback {\n    [sym]: gsap.core.Timeline;\n  }\n}\n\n/**\n * Get a GSAP timeline synced with Liqvid playback.\n */\nexport function useTimeline() {\n  const {playback} = usePlayer();\n  if (!playback[sym]) {\n    playback[sym] = syncTimeline(playback);\n  }\n  return playback[sym] as gsap.core.Timeline;\n}\n\n/**\n * Create a GSAP timeline and sync it with Liqvid playback.\n */\nfunction syncTimeline(playback: Playback) {\n  const tl = gsap.timeline({paused: true});\n\n  playback.hub.on(\"play\", () => tl.resume());\n  playback.hub.on(\"pause\", () => tl.pause());\n  playback.hub.on(\"ratechange\", () => tl.timeScale(playback.playbackRate));\n  playback.hub.on(\"seek\", () => tl.seek(playback.currentTime / 1000));\n\n  return tl;\n}\n"
  },
  {
    "path": "packages/gsap/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"module\": \"commonjs\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/host/README.md",
    "content": "# lv-host\n\nThis 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.\n"
  },
  {
    "path": "packages/host/lv-host.js",
    "content": "\"use strict\";\n\n(() => {\n  const setDims = () => {\n    document.body.style.setProperty(\"--vh\", `${window.innerHeight}px`);\n    document.body.style.setProperty(\"--vw\", `${window.innerWidth}px`);\n    document.body.style.setProperty(\"--scroll-y\", `${window.scrollY || 0}px`);\n  };\n\n  document.addEventListener(\"DOMContentLoaded\", () => {\n    // add CSS\n    {\n      const style = document.createElement(\"style\");\n      style.setAttribute(\"type\", \"text/css\");\n      style.textContent = `\n    iframe.fake-fullscreen {\n      position: fixed;\n      top: 0;/*var(--scroll-y);*/\n      left: 0;\n      height: var(--vh);\n      width: var(--vw);\n      z-index: 10000;\n    }\n\n    @media (orientation: portrait) {\n      iframe.fake-fullscreen {\n        transform: rotate(-90deg);\n        transform-origin: top left;\n        left: 0;\n        top: 100%;\n        width: var(--vh);\n        height: var(--vw);\n      }\n    }`;\n      document.head.appendChild(style);\n    }\n\n    // resize listener\n    window.addEventListener(\"resize\", setDims);\n    setDims();\n\n    // live collection of iframes\n    const iframes = document.getElementsByTagName(\"iframe\");\n\n    const listener = (e) => {\n      for (let i = 0; i < iframes.length; ++i) {\n        const iframe = iframes.item(i);\n        if (\n          iframe.allowFullscreen &&\n          !document.fullscreenEnabled &&\n          iframe.contentWindow === e.source\n        ) {\n          // handle the resize event\n          if (\"type\" in e.data && e.data.type === \"fake-fullscreen\") {\n            // resize event doesn't work reliably in iOS...\n            setDims();\n            iframe.classList.toggle(\"fake-fullscreen\", e.data.value);\n          }\n          return;\n        }\n      }\n    };\n\n    // communicate with children\n    window.addEventListener(\"message\", listener);\n  });\n})();\n"
  },
  {
    "path": "packages/host/package.json",
    "content": "{\n  \"name\": \"@liqvid/host\",\n  \"version\": \"1.1.0\",\n  \"description\": \"Liqvid host page script\",\n  \"files\": [\"lv-host.js\"],\n  \"main\": \"lv-host.js\",\n  \"keywords\": [\"Liqvid\", \"React\", \"Javascript\"],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid#readme\"\n}\n"
  },
  {
    "path": "packages/hydration/CHANGELOG.md",
    "content": "# 0.0.2 (Dec 18, 2025)\n\n- add support for [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)\n"
  },
  {
    "path": "packages/hydration/README.md",
    "content": "# @liqvid/hydration\n\nThis package provides some sneaky tricks to get around React hydration errors on statically generated sites.\n"
  },
  {
    "path": "packages/hydration/package.json",
    "content": "{\n  \"name\": \"@liqvid/hydration\",\n  \"version\": \"0.0.2\",\n  \"description\": \"Hydration magic for Liqvid\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\n        \"./dist/types/*.d.ts\"\n      ]\n    }\n  },\n  \"files\": [\n    \"dist/*\"\n  ],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean; pnpm build:js; pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"pnpm build:js:cjs; pnpm build:js:esm; pnpm build:js:fix\",\n    \"build:js:cjs\": \"tsc --module commonjs --outDir dist/cjs\",\n    \"build:js:esm\": \"tsc --module esnext --outDir dist/esm\",\n    \"build:js:fix\": \"node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"biome check --fix\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/hydration#readme\",\n  \"dependencies\": {\n    \"@liqvid/ssr\": \"workspace:\",\n    \"@radix-ui/react-slot\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"catalog:\",\n    \"@types/react\": \"catalog:\",\n    \"@types/react-dom\": \"catalog:\",\n    \"react\": \"catalog:\",\n    \"react-dom\": \"catalog:\",\n    \"typescript\": \"catalog:\"\n  },\n  \"peerDependencies\": {\n    \"react\": \">=18\"\n  },\n  \"sideEffects\": false\n}\n"
  },
  {
    "path": "packages/hydration/src/HydrateElement.tsx",
    "content": "import { isClient } from \"@liqvid/ssr\";\nimport { Root as Slot } from \"@radix-ui/react-slot\";\nimport { useId } from \"react\";\n\nimport { HydrateOnClient } from \"./HydrateOnClient\";\nimport type { ArgType, LocalValueConfig } from \"./types\";\n\nexport function HydrateElement<Config extends readonly LocalValueConfig[]>({\n  children,\n  hydrationFn,\n  ...props\n}: {\n  children: React.ReactElement;\n\n  /**\n   * You should always pass this with `as const`.\n   */\n  from: Config;\n\n  /**\n   * **🚨WARNING🚨**\n   * This does not behave like a regular JavaScript function.\n   * Instead, its literal string representation will be passed down to the client.\n   * In particular, **you cannot use any external variables or functions** within\n   * this function.\n   *\n   * To avoid confusion, you can instead pass a string; however, a function\n   * is easier to work with in your editor.\n   */\n  hydrationFn: (\n    node: HTMLElement,\n    ...args: {\n      [key in keyof Config]: ArgType<Config[key]>;\n    }\n  ) => unknown;\n}) {\n  const id = useId();\n\n  if (isClient) return children;\n\n  return (\n    <HydrateOnClient\n      hydrationFn={`(...a)=>{let n=d.getElementById(${JSON.stringify(id)});(${hydrationFn})(n,...a);n.removeAttribute(\"id\")}`}\n      {...props}\n    >\n      <Slot id={id}>{children}</Slot>\n    </HydrateOnClient>\n  );\n}\n"
  },
  {
    "path": "packages/hydration/src/HydrateOnClient.tsx",
    "content": "import { isClient } from \"@liqvid/ssr\";\n\nimport { golf } from \"./golf\";\nimport { SneakyScript } from \"./SneakyScript\";\nimport type { ArgType, LocalValueConfig } from \"./types\";\n\nexport function HydrateOnClient<Config extends readonly LocalValueConfig[]>({\n  children,\n  from,\n  hydrationFn,\n}: {\n  children?: React.ReactNode;\n\n  /**\n   * You should always pass this with `as const`.\n   */\n  from: Config;\n\n  /**\n   * ***🚨 WARNING 🚨***\n   * ***This does not behave like a regular JavaScript function.***\n   * Instead, its literal string representation will be passed down to the client.\n   * In particular, ***you cannot use any external variables or functions*** within this function.\n   *\n   * To avoid confusion, you can instead pass a string; however, a function is easier to work with in your editor.\n   */\n  hydrationFn:\n    | string\n    | ((\n        ...args: {\n          [key in keyof Config]: ArgType<Config[key]>;\n        }\n      ) => unknown);\n}) {\n  if (isClient) return <>{children}</>;\n\n  let hasCookies = false;\n  let hasLocalStorage = false;\n  let hasSessionStorage = false;\n  let hasSearchParams = false;\n\n  const args = from\n    .map((lvc) => {\n      let value: string;\n\n      switch (lvc.source ?? \"localStorage\") {\n        case \"cookie\":\n          hasCookies = true;\n          value = `${golf.cookies}[${JSON.stringify(lvc.name)}]`;\n          break;\n        case \"localStorage\":\n          hasLocalStorage = true;\n          value = `${golf.localStorage}.getItem(${JSON.stringify(lvc.name)})`;\n          break;\n        case \"sessionStorage\":\n          hasSessionStorage = true;\n          value = `${golf.sessionStorage}.getItem(${JSON.stringify(lvc.name)})`;\n          break;\n        case \"search\":\n          hasSearchParams = true;\n          value = `${golf.url}.get(${JSON.stringify(lvc.name)})`;\n          break;\n      }\n\n      switch (lvc.type ?? \"string\") {\n        case \"boolean\": {\n          const defaultValue = lvc.default ?? \"null\";\n          return `${value}?${value}==\"true\":${defaultValue}`;\n        }\n        case \"number\": {\n          if (typeof lvc.default !== \"undefined\") {\n            return `[parseFloat(${value}),${lvc.default}].find(Number.isFinite)`;\n          }\n          return `parseFloat(${value})`;\n        }\n        // https://github.com/biomejs/biome/issues/7229\n        // case \"string\":\n        default:\n          if (typeof lvc.default !== \"undefined\") {\n            return `${value}??${JSON.stringify(lvc.default)}`;\n          }\n          return value;\n      }\n    })\n    .join(\",\");\n\n  return (\n    <>\n      {children}\n      <SneakyScript>\n        {golf.comma(\n          `let ${golf.document}=document`,\n          `${golf.getElementById}=${golf.document}.getElementById.bind(d)`,\n          hasCookies && cookieScript,\n          hasLocalStorage && localStorageScript,\n          hasSessionStorage && sessionStorageScript,\n          hasSearchParams && searchScript,\n        ) + \";\"}\n        {`(${hydrationFn})(${args})`}\n      </SneakyScript>\n    </>\n  );\n}\n\nconst cookieScript = `${golf.cookies}=Object.fromEntries(${golf.document}.cookie?.split(/;\\\\s*/).map(x=>x.split(\"=\"))??[]);`;\nconst localStorageScript = `${golf.localStorage}=localStorage`;\nconst sessionStorageScript = `${golf.sessionStorage}=sessionStorage`;\nconst searchScript = `${golf.url}=new URLSearchParams(location.search)`;\n"
  },
  {
    "path": "packages/hydration/src/HydrateVariants.tsx",
    "content": "import { isClient } from \"@liqvid/ssr\";\nimport * as Slot from \"@radix-ui/react-slot\";\nimport { useId } from \"react\";\n\nimport { golf } from \"./golf\";\nimport { HydrateOnClient } from \"./HydrateOnClient\";\nimport type {\n  BooleanValueConfig,\n  ComparisonVariant,\n  NumericValueConfig,\n  NumericVariant,\n  StringValueConfig,\n  StringVariant,\n} from \"./types\";\nimport { comparisonCondition, matches, stringCondition } from \"./utils\";\n\ninterface BooleanVariantConfig extends BooleanValueConfig {\n  variants: {\n    false: React.ReactElement;\n    true: React.ReactElement;\n  };\n  value: boolean;\n}\n\nexport interface NumericVariantConfig extends NumericValueConfig {\n  variants: ComparisonVariant<number>[];\n  value: number;\n}\n\nexport interface StringVariantConfig extends StringValueConfig {\n  variants: StringVariant[];\n  value: string;\n}\n\ntype VariantConfig =\n  | BooleanVariantConfig\n  | NumericVariantConfig\n  | StringVariantConfig;\n\n/**\n * Render one of many possible variants depending on a client value\n */\nexport function HydrateVariants(props: VariantConfig) {\n  const id = useId();\n\n  const variantNodes = (() => {\n    switch (props.type) {\n      case \"boolean\": {\n        if (isClient) {\n          return props.variants[`${props.value}`];\n        }\n        return [false, true].map((variant) => (\n          <Slot.Root id={`${id}-${variant}`} key={String(variant)}>\n            {props.variants[`${variant}`]}\n          </Slot.Root>\n        ));\n      }\n      case \"number\":\n      case \"string\":\n        if (isClient) {\n          const selected = props.variants.find((variant) =>\n            matches(props.value, variant),\n          );\n          if (!selected) {\n            throw new Error(\"no matching variant\");\n          }\n\n          return <>{selected.children}</>;\n        } else {\n          return props.variants.map((variant, i) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: this is safe\n            <Slot.Root id={`${id}-${i}`} key={i}>\n              {variant.children}\n            </Slot.Root>\n          ));\n        }\n    }\n  })();\n\n  return (\n    <HydrateOnClient\n      from={[props] as const}\n      hydrationFn={\n        (props.type === \"boolean\" && booleanScript(id)) ||\n        (props.type === \"number\" && numberScript(id, props.variants)) ||\n        (props.type === \"string\" && stringScript(id, props.variants)) ||\n        \"\"\n      }\n    >\n      {variantNodes}\n    </HydrateOnClient>\n  );\n}\n\nconst booleanScript = (id: string) =>\n  [\n    `(v)=>{`,\n    `${golf.getElementById}(${JSON.stringify(`${id}-`)}+!v).remove();`,\n    `${golf.getElementById}(${JSON.stringify(`${id}-`)}+v).removeAttribute(\"id\")`,\n    `}`,\n  ].join(\"\");\n\nconst numberScript = (id: string, options: NumericVariant[]) => {\n  return golf.join(\n    `(${golf.value})=>{`,\n    ...options.map((o, index) =>\n      golf.join(\n        index === 0 ? \"let \" : \"\",\n        `${golf.node}=${golf.getElementById}(${JSON.stringify(id + \"-\" + index)});`,\n        `if(${comparisonCondition(o)})`,\n        `${golf.node}.removeAttribute(\"id\");`,\n        `else `,\n        `${golf.node}.remove();`,\n      ),\n    ),\n    `}`,\n  );\n};\n\nconst stringScript = (id: string, options: StringVariant[]) => {\n  return golf.join(\n    `(${golf.value})=>{`,\n    ...options.map((o, index) =>\n      golf.join(\n        index === 0 ? \"let \" : \"\",\n        `${golf.node}=${golf.getElementById}(${JSON.stringify(id + \"-\" + index)});`,\n        `if(${stringCondition(o)})`,\n        `${golf.node}.removeAttribute(\"id\");`,\n        `else `,\n        `${golf.node}.remove();`,\n      ),\n    ),\n    `}`,\n  );\n};\n"
  },
  {
    "path": "packages/hydration/src/SneakyScript.tsx",
    "content": "import { isClient } from \"@liqvid/ssr\";\n\ntype Joinable = false | string | Joinable[];\n\n/**\n * Render content as IIFE in a self-removing script tag\n * On the client, does nothing\n */\nexport function SneakyScript({ children }: { children: Joinable }) {\n  if (isClient) return null;\n\n  return (\n    <script>{`(()=>{${combine(children)};document.currentScript.remove()})()`}</script>\n  );\n}\n\nfunction combine(content: Joinable): string {\n  if (typeof content === \"string\") return content;\n  if (content === false) return \"\";\n\n  return content.reduce<string>((acc, curr) => {\n    if (typeof curr === \"string\") {\n      acc += curr;\n    } else if (Array.isArray(curr)) {\n      acc += combine(curr);\n    }\n\n    return acc;\n  }, \"\");\n}\n"
  },
  {
    "path": "packages/hydration/src/golf.ts",
    "content": "export const golf = {\n  comma: (...vals: (string | false)[]) => vals.filter(Boolean).join(\",\"),\n  cookies: \"c\",\n  document: \"d\",\n  getElementById: \"$\",\n\n  join: (...vals: (string | false)[]) => vals.filter(Boolean).join(\"\"),\n  localStorage: \"l\",\n  node: \"n\",\n  sessionStorage: \"s\",\n  url: \"u\",\n  value: \"v\",\n};\n"
  },
  {
    "path": "packages/hydration/src/index.ts",
    "content": "export { HydrateElement } from \"./HydrateElement\";\nexport { HydrateOnClient } from \"./HydrateOnClient\";\nexport { HydrateVariants } from \"./HydrateVariants\";\nexport { SneakyScript } from \"./SneakyScript\";\nexport type * from \"./types\";\n"
  },
  {
    "path": "packages/hydration/src/types.ts",
    "content": "/* variant configurations */\nexport interface BooleanVariant {\n  false: React.ReactElement;\n  true: React.ReactElement;\n}\n\nexport interface ComparisonVariant<T> {\n  eq?: T;\n  gt?: T;\n  gte?: T;\n  lt?: T;\n  lte?: T;\n  children: React.ReactElement;\n}\n\nexport type NumericVariant = ComparisonVariant<number>;\n\nexport interface StringVariant extends ComparisonVariant<string> {\n  contains?: string;\n  children: React.ReactElement;\n}\n\nexport interface VariantsMap {\n  boolean: BooleanVariant;\n  number: NumericVariant[];\n  string: StringVariant[];\n}\n\nexport type ClientValueSource =\n  | \"cookie\"\n  | \"localStorage\"\n  | \"search\"\n  | \"sessionStorage\";\n\n/* configuration */\ninterface BaseValueConfig {\n  name: string;\n\n  /**\n   * @default localStorage\n   */\n  source: ClientValueSource;\n}\n\nexport interface BooleanValueConfig extends BaseValueConfig {\n  default?: boolean;\n  type: \"boolean\";\n}\n\nexport interface NumericValueConfig extends BaseValueConfig {\n  default?: number;\n  type: \"number\";\n}\n\nexport interface StringValueConfig<T extends string = string>\n  extends BaseValueConfig {\n  default?: T;\n  enum?: readonly T[];\n  type?: \"string\";\n}\n\nexport type LocalValueConfig =\n  | BooleanValueConfig\n  | NumericValueConfig\n  | StringValueConfig;\n\nexport type ArgType<C extends LocalValueConfig> = C[\"type\"] extends \"boolean\"\n  ? boolean | (\"default\" extends keyof C ? never : null)\n  : C[\"type\"] extends \"number\"\n    ? number | (\"default\" extends keyof C ? never : null)\n    :\n        | (\"enum\" extends keyof C\n            ? C[\"enum\"] extends ReadonlyArray<infer E>\n              ? E\n              : never\n            : string)\n        | (\"default\" extends keyof C ? never : null);\n"
  },
  {
    "path": "packages/hydration/src/utils.ts",
    "content": "import { golf } from \"./golf\";\nimport type { ComparisonVariant, StringVariant } from \"./types\";\n\n// type Joinable = boolean | undefined | null | string | Joinable[];\n\nexport const iife = (...lines: (boolean | undefined | null | string)[]) => {\n  return `(()=>{${lines\n    .reduce<string[]>((acc, curr) => {\n      if (!curr) return acc;\n\n      if (typeof curr === \"string\") {\n        acc.push(curr);\n      } else if (Array.isArray(curr)) {\n        acc.push(...curr);\n      }\n\n      return acc;\n    }, [])\n    .join(\";\\n\")}})()`;\n};\n\nexport function matches<T extends string | number>(\n  value: T,\n  o: ComparisonVariant<T>,\n) {\n  if (typeof o.eq !== \"undefined\" && !(value === o.eq)) {\n    return false;\n  }\n  if (typeof o.lt !== \"undefined\" && !(value < o.lt)) {\n    return false;\n  }\n  if (typeof o.lte !== \"undefined\" && !(value <= o.lte)) {\n    return false;\n  }\n  if (typeof o.gt !== \"undefined\" && !(value > o.gt)) {\n    return false;\n  }\n  if (typeof o.gte !== \"undefined\" && !(value >= o.gte)) {\n    return false;\n  }\n\n  return true;\n}\n\n/**\n * Transform a thing\n */\nexport function comparisonCondition<T>(o: ComparisonVariant<T>) {\n  const conditions = [];\n\n  if (typeof o.eq !== \"undefined\") {\n    conditions.push(`${golf.value}===${JSON.stringify(o.eq)}`);\n  }\n  if (typeof o.lt !== \"undefined\") {\n    conditions.push(`${golf.value}<${JSON.stringify(o.lt)}`);\n  }\n  if (typeof o.lte !== \"undefined\") {\n    conditions.push(`${golf.value}<=${JSON.stringify(o.lte)}`);\n  }\n  if (typeof o.gt !== \"undefined\") {\n    conditions.push(`${golf.value}>${JSON.stringify(o.gt)}`);\n  }\n  if (typeof o.gte !== \"undefined\") {\n    conditions.push(`${golf.value}>=${JSON.stringify(o.gte)}`);\n  }\n\n  return conditions.join(\"&&\");\n}\n\nexport function stringCondition(o: StringVariant) {\n  return comparisonCondition(o);\n}\n"
  },
  {
    "path": "packages/hydration/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"extends\": \"../../tsconfig.json\",\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/katex/README.md",
    "content": "# @liqvid/katex\n\n[KaTeX](https://katex.org/) integration for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/integrations/katex/ for documentation.\n"
  },
  {
    "path": "packages/katex/package.json",
    "content": "{\n  \"name\": \"@liqvid/katex\",\n  \"version\": \"0.1.0\",\n  \"description\": \"KaTeX integration for Liqvid\",\n  \"files\": [\"dist/*\"],\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\"\n    },\n    \"./plain\": {\n      \"import\": \"./dist/esm/plain.mjs\",\n      \"require\": \"./dist/cjs/plain.cjs\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/types/*.d.ts\"]\n    }\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"keywords\": [\"liqvid\", \"katex\"],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:js && pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc --module esnext --outDir dist/esm; tsc --module commonjs --outDir dist/cjs; node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"eslint --ext ts,tsx --fix src\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/katex\",\n  \"license\": \"MIT\",\n  \"peerDependencies\": {\n    \"@types/katex\": \">=0.14.0\",\n    \"@types/react\": \">=18.0.0\",\n    \"liqvid\": \"workspace:^\",\n    \"react\": \">=18.1.0\"\n  },\n  \"peerDependenciesMeta\": {\n    \"liqvid\": {\n      \"optional\": true\n    }\n  },\n  \"devDependencies\": {\n    \"liqvid\": \"workspace:^\"\n  },\n  \"dependencies\": {\n    \"@liqvid/utils\": \"workspace:^\"\n  },\n  \"sideEffects\": false,\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/katex/rollup.config.js",
    "content": "import dts from \"rollup-plugin-dts\";\n\nconst external = [\"@liqvid/utils/react\", \"react\", \"react/jsx-runtime.js\"];\n\nexport default [\n  // index\n  {\n    external: [...external, \"liqvid\"],\n    input: \"dist/esm/index.mjs\",\n\n    output: [\n      // ESM\n      {file: \"./dist/index.mjs\", format: \"esm\"},\n      // CJS\n      {file: \"./dist/index.cjs\", format: \"cjs\"},\n    ],\n  },\n  // plain\n  {\n    external,\n    input: \"dist/esm/plain.mjs\",\n\n    output: [\n      // ESM\n      {file: \"./dist/plain.mjs\", format: \"esm\"},\n      // CJS\n      {file: \"./dist/plain.cjs\", format: \"cjs\"},\n    ],\n  },\n  // index types\n  {\n    input: \"dist/types/index.d.ts\",\n    plugins: [dts()],\n    output: {\n      file: \"dist/index.d.ts\",\n      format: \"es\",\n    },\n  },\n  // plain types\n  {\n    input: \"dist/types/plain.d.ts\",\n    plugins: [dts()],\n    output: {\n      file: \"dist/plain.d.ts\",\n      format: \"es\",\n    },\n  },\n];\n"
  },
  {
    "path": "packages/katex/src/RenderGroup.ts",
    "content": "import {recursiveMap, usePromise} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport React, {\n  cloneElement,\n  forwardRef,\n  isValidElement,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n} from \"react\";\nimport {KTX} from \"./fancy\";\nimport {Handle as KTXHandle, KTX as KTXPlain} from \"./plain\";\n\n/** RenderGroup element API */\ninterface Handle {\n  /** Promise that resolves once all KTX descendants have finished typesetting */\n  ready: Promise<void>;\n}\n\ninterface Props {\n  children?: React.ReactNode;\n\n  /**\n   * Whether to reparse descendants for `during()` and `from()`\n   * @default false\n   */\n  reparse?: boolean;\n}\n\n/**\n * Wait for several things to be rendered\n */\nexport const RenderGroup = forwardRef<Handle, Props>(\n  function RenderGroup(props, ref) {\n    const [ready, resolve] = usePromise();\n\n    // handle\n    useImperativeHandle(ref, () => ({ready}));\n\n    const elements = useRef<HTMLSpanElement[]>([]);\n    const promises = useRef<Promise<unknown>[]>([]);\n\n    // reparsing\n    const player = usePlayer();\n    useEffect(() => {\n      // promises\n      Promise.all(promises.current).then(() => {\n        // reparse\n        if (props.reparse) {\n          player.reparseTree(leastCommonAncestor(elements.current));\n        }\n\n        // ready()\n        resolve();\n      });\n    }, []);\n\n    return recursiveMap(props.children, (node) => {\n      if (shouldInspect(node)) {\n        const originalRef = node.ref;\n        return cloneElement(node, {\n          ref: (ref: KTXHandle) => {\n            if (!ref) return;\n\n            elements.current.push(ref.domElement);\n            promises.current.push(ref.ready);\n\n            // pass along original ref\n            if (typeof originalRef === \"function\") {\n              originalRef(ref);\n            } else if (originalRef && typeof originalRef === \"object\") {\n              (originalRef as React.MutableRefObject<KTXHandle>).current = ref;\n            }\n          },\n        });\n      }\n\n      return node;\n    }) as unknown as React.ReactElement;\n  },\n);\n\n/**\n * Determine whether the node is a <KTX> element\n * @param node Element to check\n */\nfunction shouldInspect(\n  node: React.ReactNode,\n): node is React.ReactElement & React.RefAttributes<KTXHandle> {\n  return (\n    isValidElement(node) &&\n    typeof node.type === \"object\" &&\n    (node.type === KTX || node.type === KTXPlain)\n  );\n}\n\n/**\n * Find least common ancestor of an array of elements\n * @param elements Elements\n * @returns Deepest node containing all passed elements\n */\nfunction leastCommonAncestor(elements: HTMLElement[]): HTMLElement {\n  if (elements.length === 0) {\n    throw new Error(\"Must pass at least one element\");\n  }\n\n  let ancestor = elements[0];\n  let failing = elements.slice(1);\n  while (failing.length > 0) {\n    ancestor = ancestor.parentElement;\n    failing = failing.filter((node) => !ancestor.contains(node));\n  }\n  return ancestor;\n}\n"
  },
  {
    "path": "packages/katex/src/fancy.tsx",
    "content": "import {combineRefs} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport {forwardRef, useEffect, useRef} from \"react\";\nimport {Handle, KTX as KTXPlain} from \"./plain\";\n\ninterface Props extends React.ComponentProps<typeof KTXPlain> {\n  /**\n   * Player events to obstruct\n   * @default \"canplay canplaythrough\"\n   */\n  obstruct?: string;\n\n  /**\n   * Whether to reparse descendants for `during()` and `from()`\n   * @default false\n   */\n  reparse?: boolean;\n}\n\n/** Component for KaTeX code */\nexport const KTX = forwardRef<Handle, Props>(function KTX(props, ref) {\n  const {\n    obstruct = \"canplay canplaythrough\",\n    reparse = false,\n    ...attrs\n  } = props;\n\n  const plain = useRef<Handle>();\n  const combined = combineRefs(plain, ref);\n\n  const player = usePlayer();\n\n  useEffect(() => {\n    // obstruction\n    if (obstruct.match(/\\bcanplay\\b/)) {\n      player.obstruct(\"canplay\", plain.current.ready);\n    }\n    if (obstruct.match(\"canplaythrough\")) {\n      player.obstruct(\"canplaythrough\", plain.current.ready);\n    }\n\n    // reparsing\n    if (reparse) {\n      plain.current.ready.then(() =>\n        player.reparseTree(plain.current.domElement),\n      );\n    }\n  }, []);\n\n  return <KTXPlain ref={combined} {...attrs} />;\n});\n"
  },
  {
    "path": "packages/katex/src/index.tsx",
    "content": "export {KTX} from \"./fancy\";\nexport {KaTeXReady} from \"./loading\";\nexport {Handle} from \"./plain\";\nexport {RenderGroup} from \"./RenderGroup\";\n\ndeclare global {\n  const katex: typeof katex;\n}\n"
  },
  {
    "path": "packages/katex/src/loading.ts",
    "content": "// option of loading KaTeX asynchronously\nconst KaTeXLoad = new Promise<typeof katex>((resolve) => {\n  const script = document.querySelector(\n    'script[src*=\"katex.js\"], script[src*=\"katex.min.js\"]',\n  );\n  if (!script) return;\n\n  if (window.hasOwnProperty(\"katex\")) {\n    resolve(katex);\n  } else {\n    script.addEventListener(\"load\", () => resolve(katex));\n  }\n});\n\n// load macros from <head>\nconst KaTeXMacros = new Promise<{[key: string]: string}>((resolve) => {\n  const macros: {[key: string]: string} = {};\n  const scripts: HTMLScriptElement[] = Array.from(\n    document.querySelectorAll(\"head > script[type='math/tex']\"),\n  );\n  return Promise.all(\n    scripts.map((script) =>\n      fetch(script.src)\n        .then((res) => {\n          if (res.ok) return res.text();\n          throw new Error(`${res.status} ${res.statusText}: ${script.src}`);\n        })\n        .then((tex) => {\n          Object.assign(macros, parseMacros(tex));\n        }),\n    ),\n  ).then(() => resolve(macros));\n});\n\n/**\n * Ready Promise\n */\nexport const KaTeXReady = Promise.all([KaTeXLoad, KaTeXMacros]);\n\n/**\n * Parse \\newcommand macros in a file.\n * Also supports \\ktxnewcommand (for use in conjunction with MathJax).\n * @param file TeX file to parse\n */\nfunction parseMacros(file: string) {\n  const macros: Record<string, string> = {};\n  const rgx = /\\\\(?:ktx)?newcommand\\{(.+?)\\}(?:\\[\\d+\\])?\\{/g;\n  let match: RegExpExecArray;\n\n  while ((match = rgx.exec(file))) {\n    let body = \"\";\n\n    const macro = match[1];\n    let braceCount = 1;\n\n    for (\n      let i = match.index + match[0].length;\n      braceCount > 0 && i < file.length;\n      ++i\n    ) {\n      const char = file[i];\n      if (char === \"{\") {\n        braceCount++;\n      } else if (char === \"}\") {\n        braceCount--;\n        if (braceCount === 0) break;\n      } else if (char === \"\\\\\") {\n        body += file.slice(i, i + 2);\n        ++i;\n        continue;\n      }\n      body += char;\n    }\n    macros[macro] = body;\n  }\n  return macros;\n}\n"
  },
  {
    "path": "packages/katex/src/plain.tsx",
    "content": "import {usePromise} from \"@liqvid/utils/react\";\nimport {forwardRef, useEffect, useImperativeHandle, useRef} from \"react\";\nimport {KaTeXReady} from \"./loading\";\n\n/**\n * KTX element API\n */\nexport interface Handle {\n  /** The underlying <span> element */\n  domElement: HTMLSpanElement;\n\n  /** Promise that resolves once typesetting is finished */\n  ready: Promise<void>;\n}\n\ninterface Props extends React.HTMLAttributes<HTMLSpanElement> {\n  /**\n   * Whether to render in display style\n   * @default false\n   */\n  display?: boolean;\n}\n\n/** Component for KaTeX code */\nexport const KTX = forwardRef<Handle, Props>(function KTX(props, ref) {\n  const spanRef = useRef<HTMLSpanElement>();\n  const {children, display = false, ...attrs} = props;\n  const [ready, resolve] = usePromise();\n\n  // handle\n  useImperativeHandle(ref, () => ({\n    domElement: spanRef.current,\n    ready,\n  }));\n\n  useEffect(() => {\n    KaTeXReady.then(([katex, macros]) => {\n      katex.render(children.toString(), spanRef.current, {\n        displayMode: !!display,\n        macros,\n        strict: \"ignore\",\n        throwOnError: false,\n        trust: true,\n      });\n\n      /* move katex into placeholder element */\n      const child = spanRef.current.firstElementChild as HTMLSpanElement;\n\n      // copy classes\n      for (let i = 0, len = child.classList.length; i < len; ++i) {\n        spanRef.current.classList.add(child.classList.item(i));\n      }\n\n      // move children\n      while (child.childNodes.length > 0) {\n        spanRef.current.appendChild(child.firstChild);\n      }\n\n      // delete child\n      child.remove();\n\n      // resolve promise\n      resolve();\n    });\n  }, [children]);\n\n  // Google Chrome fails without this\n  if (display) {\n    if (!attrs.style) attrs.style = {};\n    attrs.style.display = \"block\";\n  }\n\n  return <span {...attrs} ref={spanRef} />;\n});\n"
  },
  {
    "path": "packages/katex/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"module\": \"esnext\",\n    \"outDir\": \"./dist/esm\",\n    \"rootDir\": \"./src\",\n    \"target\": \"esnext\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/keymap/CHANGELOG.md",
    "content": "## 1.2.1 (January 20, 2024)\n\n- include `\"use client\"` in `@liqvid/keymap/react`\n\n## 1.2.0 (September 13, 2023)\n\n- add `useKeyboardShortcut()`\n\n## 1.1.4 (November 13, 2022)\n\n- don't throw when unbinding callback that hasn't been bound\n"
  },
  {
    "path": "packages/keymap/README.md",
    "content": "# @liqvid/keymap\n\nThis package provides key bindings for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/reference/KeyMap for documentation.\n"
  },
  {
    "path": "packages/keymap/jest.config.js",
    "content": "module.exports = {\n  preset: \"ts-jest\",\n  testEnvironment: \"jsdom\",\n  testPathIgnorePatterns: [\"dist\"],\n  coverageReporters: [\"json-summary\"],\n  transform: {},\n};\n"
  },
  {
    "path": "packages/keymap/package.json",
    "content": "{\n  \"name\": \"@liqvid/keymap\",\n  \"version\": \"1.2.2\",\n  \"description\": \"Key binding for Liqvid\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    },\n    \"./react\": {\n      \"import\": \"./dist/esm/react.mjs\",\n      \"require\": \"./dist/cjs/react.cjs\",\n      \"types\": \"./dist/types/react.d.ts\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/types/*\"]\n    }\n  },\n  \"files\": [\"dist/*\"],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:js && pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc --module esnext --outDir dist/esm; tsc --module commonjs --outDir dist/cjs; node ../../build.mjs\",\n    \"build:postclean\": \"find ./dist -name tsconfig.tsbuildinfo -delete\",\n    \"lint\": \"eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests\",\n    \"test\": \"jest\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/keymap#readme\",\n  \"sideEffects\": false,\n  \"peerDependencies\": {\n    \"@types/react\": \">=17.0.0\",\n    \"react\": \">=17.0.0\"\n  },\n  \"peerDependenciesMeta\": {\n    \"react\": {\n      \"optional\": true\n    }\n  }\n}\n"
  },
  {
    "path": "packages/keymap/src/index.ts",
    "content": "import {mixedCaseVals} from \"./mixedCaseVals\";\n\ntype Callback = (e: KeyboardEvent) => void;\n\ninterface Bindings {\n  [key: string]: Callback[];\n}\n\nconst modifierMap = {\n  Control: \"Ctrl\",\n  Alt: \"Alt\",\n  Shift: \"Shift\",\n  Meta: \"Meta\",\n};\n\nconst mixedCase: {[key: string]: string} = {};\nfor (const key of mixedCaseVals) {\n  mixedCase[key.toLowerCase()] = key;\n}\n\nconst modifierOrder = (\n  Object.keys(modifierMap) as (keyof typeof modifierMap)[]\n).map((k) => modifierMap[k]);\n\nconst useCode = [\"Backspace\", \"Enter\", \"Space\", \"Tab\"];\n\n/** Maps keyboard shortcuts to actions */\nexport class Keymap {\n  private __bindings: Bindings;\n\n  constructor() {\n    this.__bindings = {};\n  }\n\n  /** Given a KeyboardEvent, returns a shortcut sequence matching that event. */\n  static identify(e: KeyboardEvent) {\n    const parts: string[] = [];\n    for (const modifier in modifierMap) {\n      if (e.getModifierState(modifier)) {\n        parts.push(modifierMap[modifier as keyof typeof modifierMap]);\n      }\n    }\n    if (e.key in modifierMap) {\n    } else if (e.code.startsWith(\"Digit\")) {\n      parts.push(e.code.slice(5));\n    } else if (e.code.startsWith(\"Key\")) {\n      parts.push(e.code.slice(3));\n    } else if (useCode.includes(e.code)) {\n      parts.push(e.code);\n    } else {\n      parts.push(e.key);\n    }\n    return parts.join(\"+\");\n  }\n\n  /** Returns a canonical form of the shortcut sequence. */\n  static normalize(seq: string) {\n    return seq\n      .split(\"+\")\n      .map((str) => {\n        const lower = str.toLowerCase();\n\n        if (str === \"\") return \"\";\n\n        if (mixedCase[lower]) {\n          return mixedCase[lower];\n        }\n\n        return str[0].toUpperCase() + lower.slice(1);\n      })\n      .sort((a, b) => {\n        if (modifierOrder.includes(a)) {\n          if (modifierOrder.includes(b)) {\n            return modifierOrder.indexOf(a) - modifierOrder.indexOf(b);\n          } else {\n            return -1;\n          }\n        } else if (modifierOrder.includes(b)) {\n          return 1;\n        } else {\n          return cmp(a, b);\n        }\n      })\n      .join(\"+\");\n  }\n\n  /**\n   * Bind a handler to be called when the shortcut sequence is pressed.\n   * @param seq Shortcut sequence\n   * @param cb Callback function\n   */\n  bind(seq: string, cb: Callback) {\n    if (seq.indexOf(\",\") > -1) {\n      for (const atomic of seq.split(\",\")) {\n        this.bind(atomic, cb);\n      }\n      return;\n    }\n    seq = Keymap.normalize(seq);\n    if (!this.__bindings.hasOwnProperty(seq)) {\n      this.__bindings[seq] = [];\n    }\n    this.__bindings[seq].push(cb);\n  }\n\n  /**\n   * Unbind a handler from a shortcut sequence.\n   * @param seq Shortcut sequence\n   * @param cb Handler to unbind\n   */\n  unbind(seq: string, cb: Callback) {\n    if (seq.indexOf(\",\") > -1) {\n      for (const atomic of seq.split(\",\")) {\n        this.unbind(atomic, cb);\n      }\n      return;\n    }\n    seq = Keymap.normalize(seq);\n    if (!this.__bindings.hasOwnProperty(seq)) {\n      return;\n    }\n    const index = this.__bindings[seq].indexOf(cb);\n    if (index < 0) {\n      return;\n    }\n    this.__bindings[seq].splice(index, 1);\n    if (this.__bindings[seq].length === 0) {\n      delete this.__bindings[seq];\n    }\n  }\n\n  /** Return all shortcut sequences with handlers bound to them. */\n  getKeys() {\n    return Object.keys(this.__bindings);\n  }\n\n  /** Get the list of handlers for a given shortcut sequence. */\n  getHandlers(seq: string) {\n    if (!this.__bindings.hasOwnProperty(seq)) return [];\n    return this.__bindings[seq].slice();\n  }\n\n  /** Dispatches all handlers matching the given event. */\n  handle(e: KeyboardEvent) {\n    const seq = Keymap.identify(e);\n\n    if (!this.__bindings[seq] && !this.__bindings[\"*\"]) return;\n\n    if (this.__bindings[seq]) {\n      e.preventDefault();\n\n      for (const cb of this.__bindings[seq]) {\n        cb(e);\n      }\n    }\n\n    if (this.__bindings[\"*\"]) {\n      for (const cb of this.__bindings[\"*\"]) {\n        cb(e);\n      }\n    }\n  }\n}\n\n/**\n * Returns -1 if a < b, 0 if a === b, and 1 if a > b.\n */\nfunction cmp<T>(a: T, b: T) {\n  if (a < b) return -1;\n  else if (a === b) return 0;\n  return 1;\n}\n"
  },
  {
    "path": "packages/keymap/src/mixedCaseVals.ts",
    "content": "export const mixedCaseVals = [\n  \"AltGraph\",\n  \"CapsLock\",\n  \"FnLock\",\n  \"NumLock\",\n  \"ScrollLock\",\n  \"SymbolLock\",\n  \"ArrowDown\",\n  \"ArrowLeft\",\n  \"ArrowRight\",\n  \"ArrowUp\",\n  \"PageDown\",\n  \"PageUp\",\n  \"CrSel\",\n  \"EraseEof\",\n  \"ExSel\",\n  \"ContextMenu\",\n  \"ZoomIn\",\n  \"ZoomOut\",\n  \"BrightnessDown\",\n  \"BrightnessUp\",\n  \"LogOff\",\n  \"PowerOff\",\n  \"PrintScreen\",\n  \"WakeUp\",\n  \"AllCandidates\",\n  \"CodeInput\",\n  \"FinalMode\",\n  \"GroupFirst\",\n  \"GroupLast\",\n  \"GroupNext\",\n  \"GroupPrevious\",\n  \"ModeChange\",\n  \"NextCandidate\",\n  \"NonConvert\",\n  \"PreviousCandidate\",\n  \"SingleCandidate\",\n  \"HangulMode\",\n  \"HanjaMode\",\n  \"JunjaMode\",\n  \"HiraganaKatakana\",\n  \"KanaMode\",\n  \"KanjiMode\",\n  \"ZenkakuHanaku\",\n  \"AppSwitch\",\n  \"CameraFocus\",\n  \"EndCall\",\n  \"GoBack\",\n  \"GoHome\",\n  \"HeadsetHook\",\n  \"LastNumberRedial\",\n  \"MannerMode\",\n  \"VoiceDial\",\n  \"ChannelDown\",\n  \"ChannelUp\",\n  \"MediaFastForward\",\n  \"MediaPause\",\n  \"MediaPlay\",\n  \"MediaPlayPause\",\n  \"MediaRecord\",\n  \"MediaRewind\",\n  \"MediaStop\",\n  \"MediaTrackNext\",\n  \"MediaTrackPrevious\",\n  \"AudioBalanceLeft\",\n  \"AudioBalanceRight\",\n  \"AudioBassDown\",\n  \"AudioBassBoostDown\",\n  \"AudioBassBoostToggle\",\n  \"AudioBassBoostUp\",\n  \"AudioBassUp\",\n  \"AudioFaderFront\",\n  \"AudioFaderRear\",\n  \"AudioSurroundModeNext\",\n  \"AudioTrebleDown\",\n  \"AudioTrebleUp\",\n  \"AudioVolumeDown\",\n  \"AudioVolumeMute\",\n  \"AudioVolumeUp\",\n  \"MicrophoneToggle\",\n  \"MicrophoneVolumeDown\",\n  \"MicrophoneVolumeMute\",\n  \"MicrophoneVolumeUp\",\n  \"TV\",\n  \"TVAntennaCable\",\n  \"TVAudioDescription\",\n  \"TVAudioDescriptionMixDown\",\n  \"TVAudioDescriptionMixUp\",\n  \"TVContentsMenu\",\n  \"TVDataService\",\n  \"TVInput\",\n  \"TVMediaContext\",\n  \"TVNetwork\",\n  \"TVNumberEntry\",\n  \"TVPower\",\n  \"TVRadioService\",\n  \"TVSatellite\",\n  \"TVSatelliteBS\",\n  \"TVSatelliteCS\",\n  \"TVSatelliteToggle\",\n  \"TVTerrestrialAnalog\",\n  \"TVTerrestrialDigital\",\n  \"TVTimer\",\n  \"AVRInput\",\n  \"AVRPower\",\n  \"ClosedCaptionToggle\",\n  \"DisplaySwap\",\n  \"DVR\",\n  \"GuideNextDay\",\n  \"GuidePreviousDay\",\n  \"InstantReplay\",\n  \"ListProgram\",\n  \"LiveContent\",\n  \"MediaApps\",\n  \"MediaAudioTrack\",\n  \"MediaLast\",\n  \"MediaSkipBackward\",\n  \"MediaSkipForward\",\n  \"MediaStepBackward\",\n  \"MediaStepForward\",\n  \"MediaTopMenu\",\n  \"NavigateIn\",\n  \"NavigateNext\",\n  \"NavigateOut\",\n  \"NavigatePrevious\",\n  \"NextFavoriteChannel\",\n  \"NextUserProfile\",\n  \"OnDemand\",\n  \"PinPDown\",\n  \"PinPMove\",\n  \"PinPToggle\",\n  \"PinPUp\",\n  \"PlaySpeedDown\",\n  \"PlaySpeedReset\",\n  \"PlaySpeedUp\",\n  \"RandomToggle\",\n  \"RcLowBattery\",\n  \"RecordSpeedNext\",\n  \"RfBypass\",\n  \"ScanChannelsToggle\",\n  \"ScreenModeNext\",\n  \"SplitScreenToggle\",\n  \"STBInput\",\n  \"STBPower\",\n  \"VideoModeNext\",\n  \"ZoomToggle\",\n  \"SpeechCorrectionList\",\n  \"SpeechInputToggle\",\n  \"SpellCheck\",\n  \"MailForward\",\n  \"MailReply\",\n  \"MailSend\",\n  \"LaunchCalculator\",\n  \"LaunchCalendar\",\n  \"LaunchContacts\",\n  \"LaunchMail\",\n  \"LaunchMediaPlayer\",\n  \"LaunchMusicPlayer\",\n  \"LaunchMyComputer\",\n  \"LaunchPhone\",\n  \"LaunchScreenSaver\",\n  \"LaunchSpreadsheet\",\n  \"LaunchWebBrowser\",\n  \"LaunchWebCam\",\n  \"LaunchWordProcessor\",\n  \"BrowserBack\",\n  \"BrowserFavorites\",\n  \"BrowserForward\",\n  \"BrowserHome\",\n  \"BrowserRefresh\",\n  \"BrowserSearch\",\n  \"BrowserStop\",\n];\n"
  },
  {
    "path": "packages/keymap/src/react.ts",
    "content": "\"use client\";\n\nimport {createContext, useContext, useEffect} from \"react\";\nimport type {Keymap} from \".\";\n\nconst symbol = Symbol.for(\"@lqv/keymap\");\n\ntype GlobalThis = {\n  [symbol]: React.Context<Keymap>;\n};\n\nif (!(symbol in globalThis)) {\n  (globalThis as unknown as GlobalThis)[symbol] = createContext<Keymap>(null);\n}\n\n/**\n * {@link React.Context} used to access ambient Keymap\n */\nexport const KeymapContext = (globalThis as unknown as GlobalThis)[symbol];\nKeymapContext.displayName = \"Keymap\";\n\n/** Access the ambient {@link Keymap} */\nexport function useKeymap() {\n  return useContext(KeymapContext);\n}\n\n/** Register a keyboard shortcut for the duration of the component. */\nexport function useKeyboardShortcut(\n  /** Keyboard sequence to bind to */\n  seq: string,\n\n  /** Callback to handle the shortcut */\n  callback: (e: KeyboardEvent) => unknown,\n) {\n  const keymap = useKeymap();\n\n  useEffect(() => {\n    keymap.bind(seq, callback);\n\n    return () => keymap.unbind(seq, callback);\n  }, [callback, keymap, seq]);\n}\n"
  },
  {
    "path": "packages/keymap/tests/keymap.test.ts",
    "content": "import {Keymap} from \"../src/index\";\n\n/* Modifier keys cannot be tested in Keymap::identify and Keymap.handle\n   due to a bug in jsdom: https://github.com/jsdom/jsdom/issues/3126\n*/\n\ntest(\"Keymap::identify\", () => {\n  const e = new KeyboardEvent(\"keyup\", {key: \"a\", code: \"KeyA\"});\n  expect(Keymap.identify(e)).toBe(\"A\");\n});\n\ntest(\"Keymap::normalize\", () => {\n  expect(Keymap.normalize(\"A+Shift+Ctrl\")).toBe(\"Ctrl+Shift+A\");\n  expect(Keymap.normalize(\"q+alt+ctrl\")).toBe(\"Ctrl+Alt+Q\");\n});\n\ndescribe(\"Keymap bind handling\", () => {\n  const keymap = new Keymap();\n\n  const cb = jest.fn();\n  const cb2 = jest.fn();\n\n  keymap.bind(\"A\", cb);\n  keymap.bind(\"B\", cb2);\n\n  test(\"getHandlers\", () => {\n    expect(keymap.getHandlers(\"A\")).toEqual([cb]);\n    expect(keymap.getHandlers(\"B\")).toEqual([cb2]);\n  });\n\n  test(\"getKeys\", () => {\n    expect(keymap.getKeys()).toEqual([\"A\", \"B\"]);\n  });\n\n  test(\"unbind\", () => {\n    expect(() => keymap.unbind(\"C\", cb)).not.toThrow();\n    expect(() => keymap.unbind(\"B\", cb)).not.toThrow();\n    keymap.unbind(\"A\", cb);\n    expect(keymap.getHandlers(\"A\")).toEqual([]);\n  });\n\n  test(\"handle\", () => {\n    const e = new KeyboardEvent(\"keyup\", {key: \"B\", code: \"KeyB\"});\n    keymap.handle(e);\n    expect(cb2).toHaveBeenCalledTimes(1);\n    expect(cb2).toHaveBeenCalledWith(e);\n  });\n});\n"
  },
  {
    "path": "packages/keymap/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/magic/README.md",
    "content": "# @liqvid/magic\n\nThis package provides template macros for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/cli/macros/ for documentation.\n"
  },
  {
    "path": "packages/magic/jest.config.js",
    "content": "module.exports = {\n  preset: \"ts-jest\",\n  testEnvironment: \"jsdom\",\n  testPathIgnorePatterns: [\"dist\"],\n  coverageReporters: [\"json-summary\"],\n  transform: {},\n};\n"
  },
  {
    "path": "packages/magic/package.json",
    "content": "{\n  \"name\": \"@liqvid/magic\",\n  \"version\": \"1.1.2\",\n  \"description\": \"Templating functions for Liqvid\",\n  \"main\": \"./dist/index.js\",\n  \"typings\": \"./dist/index.d.ts\",\n  \"files\": [\"dist/*\"],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc --build --force\",\n    \"lint\": \"eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests\",\n    \"test\": \"jest\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/magic#readme\",\n  \"sideEffects\": false\n}\n"
  },
  {
    "path": "packages/magic/src/default-assets.ts",
    "content": "import type {ScriptData, StyleData} from \"./types\";\n\nexport const scripts: Record<string, ScriptData> = {\n  host: \"https://unpkg.com/@liqvid/host/lv-host.js\",\n  liqvid: {\n    crossorigin: true,\n    development: \"https://unpkg.com/liqvid@2.1.4/dist/liqvid.js\",\n    production: \"https://unpkg.com/liqvid@2.1.4/dist/liqvid.min.js\",\n    integrity:\n      \"sha384-o8Svf9aNpbI8MzaCkJ0rPo5OxnnZ9Zf86Z18azwsy6rPuenc22zYvNwyv49wIgWa\",\n  },\n  livereload: {},\n  polyfills: \"https://unpkg.com/@liqvid/polyfills/dist/waapi.js\",\n  rangetouch: {\n    crossorigin: true,\n    development: \"https://cdn.rangetouch.com/2.0.1/rangetouch.js\",\n    integrity:\n      \"sha384-ImWMbbJ1rSn1mn+2vsKm/wN6Vc7hPNB2VKN0lX3FAzGK+c7M2mD6ZZcwknuKlP7K\",\n    production: \"https://cdn.rangetouch.com/2.0.1/rangetouch.js\",\n  },\n  react: {\n    crossorigin: true,\n    development: \"https://unpkg.com/react@17.0.2/umd/react.development.js\",\n    production: \"https://unpkg.com/react@17.0.2/umd/react.production.min.js\",\n    integrity:\n      \"sha384-7Er69WnAl0+tY5MWEvnQzWHeDFjgHSnlQfDDeWUvv8qlRXtzaF/pNo18Q2aoZNiO\",\n  },\n  \"react-dom\": {\n    crossorigin: true,\n    development:\n      \"https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js\",\n    production:\n      \"https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js\",\n    integrity:\n      \"sha384-vj2XpC1SOa8PHrb0YlBqKN7CQzJYO72jz4CkDQ+ePL1pwOV4+dn05rPrbLGUuvCv\",\n  },\n  recording: {\n    crossorigin: true,\n    development: \"https://unpkg.com/rp-recording@2.1.1/dist/rp-recording.js\",\n  },\n};\n\nexport const styles: Record<string, StyleData> = {\n  liqvid: {\n    development: \"https://unpkg.com/liqvid@2.1.4/dist/liqvid.css\",\n    production: \"https://unpkg.com/liqvid@2.1.4/dist/liqvid.min.css\",\n  },\n};\n"
  },
  {
    "path": "packages/magic/src/index.ts",
    "content": "import type {ScriptData, StyleData} from \"./types\";\nexport type {ScriptData, StyleData} from \"./types\";\n\n/**\n * Template function.\n */\nexport function transform(\n  content: string,\n  config: {\n    mode: \"development\" | \"production\";\n    scripts: Record<string, ScriptData>;\n    styles: Record<string, StyleData>;\n  },\n) {\n  // insert scripts\n  content = content.replaceAll(\n    /<!-- @script \"(.+?)\" -->/g,\n    (match, label: string) => {\n      const script = config.scripts[label];\n      if (!script) {\n        console.warn(`Missing script ${label}`);\n        return match;\n      }\n\n      if (typeof script === \"string\") {\n        return tag(\"script\", {src: script});\n      } else {\n        const handler = script[config.mode];\n        if (!handler) {\n          return \"\";\n        }\n\n        if (typeof handler === \"string\") {\n          const attrs: Record<string, string> = {};\n\n          if (script.crossorigin) {\n            attrs.crossorigin = \"anonymous\"; //script.crossorigin;\n          }\n\n          if (config.mode === \"production\" && script.integrity) {\n            attrs.integrity = script.integrity;\n          }\n\n          attrs.src = handler;\n\n          return tag(\"script\", attrs);\n        } else {\n          return tag(\"script\", {}, handler);\n        }\n      }\n    },\n  );\n\n  // insert styles\n  content = content.replaceAll(\n    /<!-- @style \"(.+?)\" -->/g,\n    (match, label: string) => {\n      const style = config.styles[label];\n      if (!style) {\n        console.warn(`Missing style ${label}`);\n        return match;\n      }\n\n      const attrs: Record<string, string> = {\n        rel: \"stylesheet\",\n        type: \"text/css\",\n      };\n\n      if (typeof style === \"string\") {\n        return tag(\"link\", {href: style, ...attrs}, true);\n      } else {\n        const handler = style[config.mode];\n        if (!handler) {\n          return \"\";\n        }\n\n        if (typeof handler === \"string\") {\n          return tag(\"link\", {href: style[config.mode], ...attrs}, true);\n        }\n      }\n\n      return tag(\"link\", attrs, true);\n    },\n  );\n\n  // insert json\n  content = content.replaceAll(\n    /<!-- @json \"(.+?)\" \"(.+?)\" -->/g,\n    (match, label: string, src: string) => {\n      return tag(\n        \"link\",\n        {\n          as: \"fetch\",\n          \"data-name\": label,\n          href: src,\n          rel: \"preload\",\n          type: \"application/json\",\n        },\n        true,\n      );\n    },\n  );\n\n  // return\n  return content;\n}\n\n/**\n * Create an HTML tag.\n */\nexport function tag<K extends keyof HTMLElementTagNameMap>(\n  tagName: K,\n  attrs: Record<string, boolean | string> = {},\n  nextOrClose: boolean | number | string | (() => string) = false,\n) {\n  const close = nextOrClose === true;\n\n  const attrString = Object.keys(attrs)\n    .map((attr) => {\n      if (!attrs.hasOwnProperty(attr)) return \"\";\n\n      if (\"boolean\" === typeof attrs[attr]) {\n        if (attrs[attr]) return ` ${attr}`;\n        return \"\";\n      }\n\n      // XXX make sure this is correct escaping\n      const escaped = attrs[attr].toString().replace(/\"/g, \"&quot;\");\n\n      return ` ${attr}=\"${escaped}\"`;\n    })\n    .join(\"\");\n\n  const str = `<${tagName}${attrString}`;\n\n  if (close) return `${str}/>`;\n\n  let content;\n  switch (typeof nextOrClose) {\n    case \"function\":\n      content = nextOrClose();\n      break;\n    case \"number\":\n    case \"string\":\n      content = nextOrClose;\n      break;\n    default:\n      content = \"\";\n      break;\n  }\n\n  return `${str}>${content}</${tagName}>`;\n}\n\nexport {scripts, styles} from \"./default-assets\";\n"
  },
  {
    "path": "packages/magic/src/types.ts",
    "content": "export type ScriptData =\n  | {\n      /**\n       * Whether script is crossorigin.\n       * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-crossorigin\n       */\n      crossorigin?: boolean | string;\n\n      /**\n       * Whether to apply the defer attribute\n       * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-defer\n       */\n      defer?: boolean;\n\n      /**\n       * Development src.\n       */\n      development?: string | (() => string);\n\n      /**\n       * Integrity attribute for production.\n       */\n      integrity?: string;\n\n      /**\n       * Production src.\n       */\n      production?: string | (() => string);\n    }\n  | string;\n\nexport type StyleData =\n  | {\n      /**\n       * Development href.\n       */\n      development?: string;\n\n      /**\n       * Production href.\n       */\n      production?: string;\n    }\n  | string;\n"
  },
  {
    "path": "packages/magic/tests/magic.test.ts",
    "content": "import exp from \"constants\";\nimport {tag, transform, scripts, styles} from \"..\";\n\njest.spyOn(console, \"warn\").mockImplementation(() => {});\n\n// describe(\"default assets\", () => {\n\n// });\n\ntest(\"@json\", () => {\n  const content = `<!-- @json \"test\" \"./test.json\" -->`;\n  const str = transform(content, {\n    mode: \"development\",\n    scripts: {},\n    styles: {},\n  });\n\n  expect(str).toBe(\n    `<link as=\"fetch\" data-name=\"test\" href=\"./test.json\" rel=\"preload\" type=\"application/json\"/>`,\n  );\n});\n\ndescribe(\"@script\", () => {\n  const config = {\n    scripts: {\n      basic: {\n        development: \"https://dev.com\",\n        production: \"https://prod.com\",\n      },\n      devOnly: {\n        development: \"https://dev.only\",\n      },\n      prodOnly: {\n        production: \"https://prod.only\",\n      },\n      single: \"https://same-url.com\",\n      withIntegrity: {\n        crossorigin: \"anonymous\",\n        defer: true,\n        integrity: \"sha384\",\n        development: \"https://dev.com\",\n        production: \"https://prod.com\",\n      },\n    },\n    styles: {},\n  };\n\n  test(\"single script\", () => {\n    const content = `<!-- @script \"single\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\n      `<script src=\"https://same-url.com\"></script>`,\n    );\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\n      `<script src=\"https://same-url.com\"></script>`,\n    );\n  });\n\n  test(\"mode selection\", () => {\n    const content = `<!-- @script \"basic\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\n      `<script src=\"https://dev.com\"></script>`,\n    );\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\n      `<script src=\"https://prod.com\"></script>`,\n    );\n  });\n\n  test(\"integrity attribute\", () => {\n    const content = `<!-- @script \"withIntegrity\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\n      `<script crossorigin=\"anonymous\" src=\"https://dev.com\"></script>`,\n    );\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\n      `<script crossorigin=\"anonymous\" integrity=\"sha384\" src=\"https://prod.com\"></script>`,\n    );\n  });\n\n  test(\"complain about missing script\", () => {\n    const content = `<!-- @script \"missing\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(content);\n    expect(console.warn).toHaveBeenCalledWith(\"Missing script missing\");\n    expect(transform(content, {mode: \"production\", ...config})).toBe(content);\n    expect(console.warn).toHaveBeenCalledWith(\"Missing script missing\");\n  });\n\n  test(\"dev only\", () => {\n    const content = `<!-- @script \"devOnly\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\n      `<script src=\"https://dev.only\"></script>`,\n    );\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\"\");\n  });\n\n  test(\"prod only\", () => {\n    const content = `<!-- @script \"prodOnly\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\"\");\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\n      `<script src=\"https://prod.only\"></script>`,\n    );\n  });\n});\n\ndescribe(\"@styles\", () => {\n  const config = {\n    scripts: {},\n    styles: {\n      basic: {\n        development: \"https://dev.com\",\n        production: \"https://prod.com\",\n      },\n      devOnly: {\n        development: \"https://dev.only\",\n      },\n      prodOnly: {\n        production: \"https://prod.only\",\n      },\n      single: \"https://same-url.com\",\n      withIntegrity: {\n        crossorigin: \"anonymous\",\n        defer: true,\n        integrity: \"sha384\",\n        development: \"https://dev.com\",\n        production: \"https://prod.com\",\n      },\n    },\n  };\n\n  test(\"single style\", () => {\n    const content = `<!-- @style \"single\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\n      `<link href=\"https://same-url.com\" rel=\"stylesheet\" type=\"text/css\"/>`,\n    );\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\n      `<link href=\"https://same-url.com\" rel=\"stylesheet\" type=\"text/css\"/>`,\n    );\n  });\n\n  test(\"mode selection\", () => {\n    const content = `<!-- @style \"basic\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\n      `<link href=\"https://dev.com\" rel=\"stylesheet\" type=\"text/css\"/>`,\n    );\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\n      `<link href=\"https://prod.com\" rel=\"stylesheet\" type=\"text/css\"/>`,\n    );\n  });\n\n  test(\"complain about missing style\", () => {\n    const content = `<!-- @style \"missing\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(content);\n    expect(console.warn).toHaveBeenCalledWith(\"Missing style missing\");\n    expect(transform(content, {mode: \"production\", ...config})).toBe(content);\n    expect(console.warn).toHaveBeenCalledWith(\"Missing style missing\");\n  });\n\n  test(\"dev only\", () => {\n    const content = `<!-- @style \"devOnly\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\n      `<link href=\"https://dev.only\" rel=\"stylesheet\" type=\"text/css\"/>`,\n    );\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\"\");\n  });\n\n  test(\"prod only\", () => {\n    const content = `<!-- @style \"prodOnly\" -->`;\n    expect(transform(content, {mode: \"development\", ...config})).toBe(\"\");\n    expect(transform(content, {mode: \"production\", ...config})).toBe(\n      `<link href=\"https://prod.only\" rel=\"stylesheet\" type=\"text/css\"/>`,\n    );\n  });\n});\n\ndescribe(\"tag\", () => {\n  test(\"no args\", () => {\n    expect(tag(\"p\")).toBe(`<p></p>`);\n  });\n\n  test(\"attrs\", () => {\n    expect(tag(\"script\", {src: \"test.js\", type: \"text/javascript\"})).toBe(\n      `<script src=\"test.js\" type=\"text/javascript\"></script>`,\n    );\n  });\n\n  test(\"boolean attribute\", () => {\n    expect(tag(\"script\", {crossorigin: true, src: \"test.js\"})).toBe(\n      `<script crossorigin src=\"test.js\"></script>`,\n    );\n  });\n\n  test(\"function content\", () => {\n    expect(tag(\"a\", {href: \"test.html\"}, () => \"Click Here\")).toBe(\n      `<a href=\"test.html\">Click Here</a>`,\n    );\n  });\n\n  test(\"escape quotes\", () => {\n    expect(tag(\"span\", {title: `\"this is a test\"`}, \"Hello\")).toBe(\n      `<span title=\"&quot;this is a test&quot;\">Hello</span>`,\n    );\n  });\n\n  test(\"self-closing tag\", () => {\n    expect(tag(\"link\", {href: \"test.css\"}, true)).toBe(\n      `<link href=\"test.css\"/>`,\n    );\n  });\n});\n"
  },
  {
    "path": "packages/magic/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"module\": \"none\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/main/CHANGELOG.md",
    "content": "## 2.1.18 (April 27, 2025)\n\n- fix `Script` type to be readonly + allow narrowing the marker name\n\n## 2.1.17 (February 4, 2025)\n\n- fix ESM exports\n\n## 2.1.15 (January 24, 2025)\n\n- fix `StrictMode` error in `<CaptionsDisplay>`\n\n## 2.1.14 (January 23, 2025)\n\n- make `Player.symbol` indexable via `@liqvid/player/element`\n\n## 2.1.12 (June 12, 2024)\n\n- Strict Mode fixes\n\n## 2.1.10 (June 6, 2024)\n\n- make compatible with Next.js / SSR\n\n## 2.1.9 (November 13, 2022)\n\n- `<Audio>`/`<Video>` will seek to their end when `Playback` is seeked past their end\n\n- `<Audio>`/`<Video>` will restart when `Playback` plays from ended\n\n- `Playback` will fire `stop` and restart correctly when seeked to end (vs played to end)\n\n- `Keymap` no longer throws Errors when calling `unbind()` with an unbound callback\n\n## 2.1.8 (October 29, 2022)\n\n- fix `<Audio>`/`<Video>` pausing playback on end (#31)\n\n## 2.1.7 (May 14, 2022)\n\n- support React 18\n\n## 2.1.6 (May 6, 2022)\n\n- ensure `Player.Context` is always the same even if multiple versions of Liqvid are accidentally loaded\n\n## 2.1.5 (May 4, 2022)\n\n- allow passing numeric durations to `Script` (fixes #26)\n\n## 2.1.4 (March 15, 2022)\n\n- don't crash when `Playback` isn't polyfilled\n\n- update repository URL\n\n## 2.1.3 (March 13, 2022)\n\n- fix `fake-fullscreen` origin\n\n## 2.1.2 (March 13, 2022)\n\n- use Rollup instead of Webpack\n\n- correctly transpile dependencies for old browsers\n\n## 2.1.1 (March 12, 2022)\n\n- fix typings in `package.json`\n\n## 2.1.0 (March 12, 2022)\n\n### New features\n\n- add `Playback.timeline` and `Playback.newAnimation` for much easier animation\n\n- add `Utils.json` and `Utils.svg`\n\n- add `Utils.react.combineRefs`\n\n- put `Utils.animation.bezier` and `Utils.animation.easings` back in\n\n- add `useKeymap`, `usePlayback`, `useScript` hooks\n\n- add `useTime` hook\n\n- distribute as ES module\n\n### Ease-of-use\n\n- `start` prop on `<Audio>` and `<Video>` elements now defaults to `0`\n\n- add defaults to `thumbs` prop\n\n- attach events directly to `Playback` and `Script` instead of `.hub`\n\n- can now use `Player` without `Script`\n\n- add `--lv-canvas-height` CSS variable\n\n- add `data-affords=\"click\"` for cancelling `canvasClick`\n\n### Miscellaneous\n\n- rename library to Liqvid\n\n- expose `Media` class\n\n- expose `ScrubberBar` control\n\n- improve captions support, add captions control\n\n- rename `KeyMap` to `Keymap`\n\n- move most internals to `@liqvid` namespace\n\n## 2.0.10 (Jul 19, 2021)\n\n- add `playsInline` to `<Video>`\n\n## 2.0.9 (Jul 19, 2021)\n\n- fix bug introduced in 2.0.6 where Media ending would pause playback\n\n## 2.0.8 (Jul 19, 2021)\n\n- fix bug where scrubber keys could not be properly reassigned\n\n- fix normalization in Script constructor\n\n## 2.0.7 (Jul 7, 2021)\n\n- package as UMD\n\n## 2.0.6 (Jun 7, 2021)\n\n- work correctly with keyboard play/pause buttons\n\n- make scrubber bar work on desktop touchscreens\n\n- no longer necessary to call `.ready()`, now a noop\n\n- more intelligent canvasClick/keyCapture behavior\n\n- enable captions\n\n## 2.0.5 (May 28, 2021)\n\n- correctly remove listeners when unmounting `<Audio>`/`<Video>`\n\n- remove silly `<Video>` hiding behavior\n\n- add `Script.playback` to typings\n\n## 2.0.4 (May 9, 2021)\n\n- fix bug in `KeyMap.normalize` + mistyping as `KeyMap.canonize`\n\n## 2.0.2/2.0.3 (Jan 22, 2021)\n\n- fix bug in mobile styling\n\n## 2.0.1 (Jan 10, 2021)\n\n- `KeyMap.getHandlers` will return `[]` on unbound sequences instead of throwing error\n\n## 2.0.0 (Dec 31, 2020)\n\n- remove Cursor; use [rp-cursor](https://www.npmjs.com/package/rp-cursor) instead\n\n- rename `Player.$controls` -> `Player.controls`\n\n- remove `Player.CONTROLS_HEIGHT`\n\n- support ordinary events in `Player.preventCanvasClick`\n\n- added `Player.allowScroll`\n\n- added `Script.parseStart` and `Script.parseEnd`\n\n- added `Utils.time.timeRegexp`\n\n- added `Utils.replayData`\n\n- added some documentation\n\n- workaround for https://github.com/facebook/react/issues/2043 affecting Android (now fixed in React v.17)\n\n- added `Utils.react.captureRef`\n\n- added `useMarkerUpdate`, `usePlayer`, `useTimeUpdate` hooks\n\n- added `rp-volume-color` CSS variable\n\n- removed `rememberVolumeSettings` due to cookie laws\n\n- added `KeyMap`\n\n- removed plugin system and \"hooks\" system (easily confused with React's Hooks); added `Player.props.controls` and `Player.defaultControls*` to replace\n\n- removed `LoadingScreen`\n\n- added `Player.reparseTree`\n\n- allowed `Utils.misc.range` to take two arguments\n\n## 1.1.1 (October 20, 2019)\n\n- fix typings for `utils/animation/replay`\n\n## 1.1.0 (October 20, 2019)\n\n- add `attachClickHandler` in `utils/mobile`\n\n## 1.0.3 (October 20, 2019)\n\n- better mobile scrubbing\n\n- remove external fonts\n\n- work around opacity bug on Safari\n\n## 1.0.2 (September 6, 2019)\n\n- use `StrictEventEmitter` for better typing\n\n## 1.0.1 (September 2, 2019)\n\n- specify `files` correctly in `package.json`\n\n## 1.0.0 (September 2, 2019)\n\nFirst stable release\n\n## 0.8.0 (November 9, 2018)\n\nInitial public release\n"
  },
  {
    "path": "packages/main/DEVELOPMENT.md",
    "content": "## Testing\n\nIn order for media codecs to work in the e2e tests, Playwright may need your system Chromium instead of its bundled one. To configure this, rename `.env.example` to `.env` and adjust `PLAYWRIGHT_EXECUTABLE_PATH` as necessary.\n"
  },
  {
    "path": "packages/main/README.md",
    "content": "# liqvid\n\nThis is a library for making **interactive** videos in React.\n\nFor example, here's an interactive coding demo inside a video:\n\n<a href=\"https://gfycat.com/frailtemptingeyra\"><img src=\"https://thumbs.gfycat.com/FrailTemptingEyra-size_restricted.gif\"/></a>\n\nHere's an interactive graph:\n\n<a href=\"https://gfycat.com/magnificentdopeybrownbear\"><img src=\"https://thumbs.gfycat.com/MagnificentDopeyBrownbear-size_restricted.gif\"/></a>\n\nTo get started, go to https://liqvidjs.org/docs/\n\nFor inspiration, see https://epiplexis.xyz/\n"
  },
  {
    "path": "packages/main/e2e/app/package.json",
    "content": "{\n  \"private\": true,\n  \"description\": \"E2E tests for Liqvid\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"build\": \"webpack\",\n    \"dev\": \"concurrently \\\"pnpm watch\\\" \\\"pnpm serve\\\"\",\n    \"serve\": \"serve -p 41728 -S -s static\",\n    \"watch\": \"webpack --watch\"\n  },\n  \"keywords\": [],\n  \"author\": \"\",\n  \"license\": \"ISC\",\n  \"dependencies\": {\n    \"@liqvid/cli\": \"workspace:^\",\n    \"@liqvid/recording\": \"workspace:^\",\n    \"liqvid\": \"workspace:^\"\n  },\n  \"devDependencies\": {\n    \"ts-loader\": \"^9.3.1\",\n    \"typescript\": \"^4.8.4\",\n    \"webpack\": \"^5.74.0\",\n    \"webpack-cli\": \"^4.10.0\"\n  }\n}\n"
  },
  {
    "path": "packages/main/e2e/app/src/index.tsx",
    "content": "import {createRoot} from \"react-dom/client\";\n\nimport * as Liqvid from \"../../../src/index\";\nimport {Playback, Player, Video} from \"../../../src/index\";\n\n// simplifies testing for now\nwindow.Liqvid = Liqvid;\n\nconst playback = new Playback({duration: 60000});\n\nfunction Lesson() {\n  return (\n    <Player playback={playback}>\n      <Video start={10000}>\n        <source src={process.env.PLAYWRIGHT_TEST_VIDEO} type=\"video/mp4\" />\n      </Video>\n    </Player>\n  );\n}\n\ncreateRoot(document.querySelector(\"main\")).render(<Lesson />);\n"
  },
  {
    "path": "packages/main/e2e/app/static/index.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <title></title>\n\n  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1, maximum-scale=1\" />\n\n  <link href=\"./liqvid.min.css\" rel=\"stylesheet\" />\n  <link href=\"./style.css\" rel=\"stylesheet\" />\n</head>\n\n<body>\n  <main></main>\n\n  <script src=\"./bundle.js\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "packages/main/e2e/app/static/style.css",
    "content": "video {\n  width: 100%;\n}\n"
  },
  {
    "path": "packages/main/e2e/app/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"alwaysStrict\": true,\n    \"incremental\": true,\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\"es2015\", \"es2016\", \"es2017\", \"dom\"],\n    \"moduleResolution\": \"node\",\n    \"pretty\": true,\n    \"removeComments\": true,\n    \"target\": \"es2017\",\n\n    \"paths\": {\n      \"@env/*\": [\"./src/@development/*\", \"./src/@production/*\"]\n    }\n  },\n  \"files\": [\"./src/index.tsx\"]\n}\n"
  },
  {
    "path": "packages/main/e2e/app/webpack.config.js",
    "content": "const TerserPlugin = require(\"terser-webpack-plugin\");\nconst path = require(\"path\");\nconst env = process.env.NODE_ENV || \"development\";\nrequire(\"dotenv\").config({path: \"../../.env\"});\nconst webpack = require(\"webpack\");\n\nmodule.exports = {\n  entry: `./src/index.tsx`,\n  output: {\n    filename: \"bundle.js\",\n    path: path.join(__dirname, \"static\"),\n  },\n\n  mode: env,\n\n  module: {\n    rules: [\n      {\n        test: /\\.[jt]sx?$/,\n        loader: \"ts-loader\",\n      },\n    ],\n  },\n\n  plugins: [new webpack.EnvironmentPlugin([\"PLAYWRIGHT_TEST_VIDEO\"])],\n\n  // necessary due to bug in old versions of mobile Safari\n  devtool: false,\n\n  optimization: {\n    minimizer: [\n      new TerserPlugin({\n        parallel: true,\n        terserOptions: {\n          safari10: true,\n        },\n      }),\n    ],\n    emitOnErrors: true,\n  },\n\n  resolve: {\n    extensions: [\".ts\", \".tsx\", \".js\", \".jsx\", \".json\"],\n    alias: {\n      \"@env\": path.join(__dirname, \"src\", \"@\" + env),\n    },\n  },\n};\n"
  },
  {
    "path": "packages/main/e2e/tests/Media.spec.tsx",
    "content": "import {ElementHandle, expect, JSHandle, test} from \"@playwright/test\";\nimport type {Playback, Player} from \"../../src/index\";\n\ntest.describe(\"Media\", () => {\n  let playback: JSHandle<Playback>;\n  let player: JSHandle<Player>;\n  let video: ElementHandle<HTMLVideoElement>;\n\n  test.beforeEach(async ({page}) => {\n    await page.goto(\"/\");\n\n    // globals\n    player = await page.evaluateHandle(() => {\n      return (document.querySelector(\".lv-player\") as HTMLDivElement)[\n        window.Liqvid.Player.symbol\n      ] as Player;\n    });\n    playback = await player.evaluateHandle((player) => player.playback);\n\n    // load video\n    const locator = page.locator(\"video\");\n    await locator.waitFor();\n    await locator.evaluate<void, HTMLVideoElement>((video) =>\n      window.Liqvid.Utils.media.awaitMediaCanPlay(video),\n    );\n\n    // create handle\n    video = (await locator.elementHandle()) as ElementHandle<HTMLVideoElement>;\n  });\n\n  test(\"seeking past video.duration should seek to video end\", async () => {\n    await playback.evaluate((p) => p.seek(p.duration));\n    expect(await video.evaluate((v) => v.currentTime === v.duration)).toBe(\n      true,\n    );\n  });\n\n  test(\"restarting playback should restart video\", async () => {\n    await playback.evaluate((p) => {\n      p.seek(p.duration);\n    });\n    expect(await video.evaluate((v) => v.currentTime === v.duration)).toBe(\n      true,\n    );\n    // don't batch with the previous evaluate or else video won't have time to update\n    await playback.evaluate((p) => p.play());\n    expect(await video.evaluate((v) => v.currentTime)).toBe(0);\n  });\n});\n"
  },
  {
    "path": "packages/main/jest.config.js",
    "content": "module.exports = {\n  preset: \"ts-jest\",\n  testEnvironment: \"jsdom\",\n  testPathIgnorePatterns: [\"dist\", \"e2e\"],\n  transform: {},\n};\n"
  },
  {
    "path": "packages/main/package.json",
    "content": "{\n  \"name\": \"liqvid\",\n  \"version\": \"2.1.19\",\n  \"description\": \"Library for playing interactive videos using HTML/CSS/Javascript\",\n  \"files\": [\"dist/*\"],\n  \"main\": \"./dist/liqvid.js\",\n  \"module\": \"./dist/liqvid.mjs\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    },\n    \"./dist/liqvid.css\": \"./dist/liqvid.css\",\n    \"./dist/liqvid.min.css\": \"./dist/liqvid.min.css\",\n    \"./liqvid.css\": \"./dist/liqvid.css\",\n    \"./liqvid.min.css\": \"./dist/liqvid.min.css\"\n  },\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:css && pnpm build:js\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:css\": \"stylus -o dist/liqvid.css styl/liqvid.styl; stylus -c -o dist/liqvid.min.css styl/liqvid.styl\",\n    \"build:js\": \"pnpm build:js:bundle; pnpm build:js:cjs; pnpm build:js:esm; pnpm build:js:fix\",\n    \"build:js:bundle\": \"tsc && rollup -c && rm -rf dist/esm\",\n    \"build:js:cjs\": \"tsc --module commonjs --outDir dist/cjs\",\n    \"build:js:esm\": \"tsc --module esnext --outDir dist/esm\",\n    \"build:js:fix\": \"node ../../build.mjs\",\n    \"lint\": \"eslint --ext ts,tsx --fix e2e src tests\",\n    \"stylus\": \"stylus -o dist/liqvid.css -w styl/liqvid.styl\",\n    \"stylus:prod\": \"stylus -c -o dist/liqvid.min.css -w styl/liqvid.styl\",\n    \"test\": \"pnpm test:jest && pnpm test:build-e2e && pnpm test:playwright\",\n    \"test:build-e2e\": \"cd e2e/app && pnpm build && cd ../..\",\n    \"test:jest\": \"jest\",\n    \"test:playwright\": \"npx playwright test\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/master/packages/main#readme\",\n  \"devDependencies\": {\n    \"nib\": \"^1.1.2\",\n    \"stylus\": \"^0.57.0\",\n    \"tslib\": \"^2.4.0\",\n    \"typedoc\": \"^0.22.15\",\n    \"typedoc-plugin-markdown\": \"^3.12.1\"\n  },\n  \"dependencies\": {\n    \"@liqvid/keymap\": \"workspace:^\",\n    \"@liqvid/playback\": \"workspace:^\",\n    \"@liqvid/utils\": \"workspace:^\",\n    \"@types/events\": \"^3.0.0\",\n    \"@types/node\": \"^22.10.10\",\n    \"events\": \"^3.3.0\",\n    \"strict-event-emitter-types\": \"^2.0.0\"\n  },\n  \"peerDependencies\": {\n    \"@types/react\": \">=17\",\n    \"@types/react-dom\": \">=17\",\n    \"react\": \">=17\",\n    \"react-dom\": \">=17\"\n  }\n}\n"
  },
  {
    "path": "packages/main/playwright.config.ts",
    "content": "import dotenv from \"dotenv\";\ndotenv.config();\n\nimport type {PlaywrightTestConfig} from \"@playwright/test\";\nconst config: PlaywrightTestConfig = {\n  testDir: \"e2e/tests\",\n  use: {\n    baseURL: process.env.PLAYWRIGHT_HOST,\n    headless: true,\n    launchOptions: {\n      executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH,\n    },\n    viewport: {width: 1280, height: 720},\n    ignoreHTTPSErrors: true,\n    video: \"off\",\n  },\n  webServer: {\n    command: \"cd e2e/app && pnpm serve\",\n    url: process.env.PLAYWRIGHT_HOST,\n    reuseExistingServer: !process.env.CI,\n  },\n};\nexport default config;\n"
  },
  {
    "path": "packages/main/rollup.config.js",
    "content": "import * as fs from \"fs\";\nimport {getBabelOutputPlugin} from \"@rollup/plugin-babel\";\nimport commonjs from \"@rollup/plugin-commonjs\";\nimport {nodeResolve} from \"@rollup/plugin-node-resolve\";\nimport dts from \"rollup-plugin-dts\";\nimport {terser} from \"rollup-plugin-terser\";\n\n// banner\nconst licenseComment = \"/*!\" + fs.readFileSync(\"./LICENSE\", \"utf8\") + \"*/\";\nconst useClientDirective = '\"use client\";';\nconst banner = `${licenseComment}\\n${useClientDirective}`;\n\n/* shared UMD config --- don't put plugins here bc array will get copied by reference */\nconst umdConfig = {\n  banner,\n  format: \"esm\",\n  globals: {\n    react: \"React\",\n    \"react-dom\": \"ReactDOM\",\n  },\n};\n\n// babel config\nconst babelConfig = () =>\n  getBabelOutputPlugin({\n    plugins: [\n      [\n        \"@babel/plugin-transform-modules-umd\",\n        {\n          globals: {\n            react: \"React\",\n            \"react-dom\": \"ReactDOM\",\n          },\n          moduleId: \"Liqvid\",\n          moduleRoot: \"Liqvid\",\n        },\n      ],\n    ],\n    presets: [[\"@babel/env\", {targets: {ios: \"12\"}}]],\n  });\n\nexport default [\n  {\n    external: [\"react\", \"react-dom\"],\n    input: \"dist/esm/index.js\",\n    plugins: [nodeResolve({preferBuiltins: false}), commonjs()],\n\n    output: [\n      // ESM\n      {\n        banner,\n        file: \"./dist/liqvid.mjs\",\n        format: \"esm\",\n      },\n      // UMD development\n      {\n        ...umdConfig,\n        file: \"./dist/liqvid.js\",\n        plugins: [babelConfig()],\n      },\n      // UMD production\n      {\n        ...umdConfig,\n        file: \"./dist/liqvid.min.js\",\n        plugins: [babelConfig(), terser({module: false, safari10: true})],\n      },\n    ],\n  },\n  // types\n  {\n    input: \"dist/types/index.d.ts\",\n    plugins: [dts()],\n    output: {\n      file: \"dist/liqvid.d.ts\",\n      format: \"es\",\n    },\n  },\n];\n"
  },
  {
    "path": "packages/main/src/Audio.tsx",
    "content": "import * as React from \"react\";\nimport {Media} from \"./Media\";\n\nimport {fragmentFromHTML} from \"./utils/dom\";\n\n/** Liqvid equivalent of {@link HTMLAudioElement `<audio>`}. */\nexport class Audio extends Media {\n  /** The underlying <audio> element. */\n  declare domElement: HTMLAudioElement;\n\n  componentDidMount() {\n    super.componentDidMount();\n\n    // tracks\n    for (const track of Array.from(this.domElement.textTracks)) {\n      if (![\"captions\", \"subtitles\"].includes(track.kind)) continue;\n      let mode = track.mode;\n      track.addEventListener(\"cuechange\", () => {\n        if (track.mode !== \"showing\") {\n          if (mode === \"showing\") this.playback.captions = [];\n          mode = track.mode;\n          return;\n        }\n        mode = track.mode;\n        const captions = [];\n        for (const cue of Array.from(track.activeCues)) {\n          // @ts-expect-error check this I guess\n          const html = cue.text.replace(/\\n/g, \"<br/>\");\n          captions.push(fragmentFromHTML(html));\n        }\n        this.playback.captions = captions;\n      });\n    }\n  }\n\n  // render method\n  render() {\n    const {start, obstructCanPlay, obstructCanPlayThrough, children, ...attrs} =\n      this.props;\n\n    return (\n      <audio preload=\"auto\" ref={(node) => (this.domElement = node)} {...attrs}>\n        {children}\n      </audio>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/main/src/CaptionsDisplay.tsx",
    "content": "import * as React from \"react\";\nimport {useEffect, useRef} from \"react\";\n\nimport {usePlayback} from \"@liqvid/playback/react\";\n\nexport default function Captions() {\n  const playback = usePlayback();\n  const domElement = useRef<HTMLDivElement>();\n\n  useEffect(() => {\n    const updateCaptions = () => {\n      domElement.current.innerHTML = \"\";\n      for (const cue of playback.captions) {\n        domElement.current.appendChild(cue);\n      }\n    };\n\n    playback.on(\"cuechange\", updateCaptions);\n\n    return () => {\n      playback.off(\"cuechange\", updateCaptions);\n    };\n  }, [playback]);\n\n  return <div className=\"lv-captions-display\" ref={domElement} />;\n}\n"
  },
  {
    "path": "packages/main/src/Controls.tsx",
    "content": "import * as React from \"react\";\nimport {useCallback, useEffect, useRef, useState} from \"react\";\n\nimport {ScrubberBar, ThumbData} from \"./controls/ScrubberBar\";\nimport {useKeymap} from \"@liqvid/keymap/react\";\nimport {usePlayback} from \"@liqvid/playback/react\";\nimport {Player} from \"./Player\";\n\ninterface Props {\n  controls: JSX.Element | JSX.Element[];\n  thumbs?: ThumbData;\n}\n\n// hiding timeout\nconst TIMEOUT = 3000;\n\nexport default function Controls(props: Props) {\n  const keymap = useKeymap();\n  const playback = usePlayback();\n  const [visible, setVisible] = useState(true);\n\n  const timer = useRef(0);\n\n  // reset the hiding timer\n  const resetTimer = useCallback(() => {\n    if (playback.paused) return;\n    if (timer.current !== undefined) clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setVisible(false), TIMEOUT);\n    setVisible(true);\n  }, [playback]);\n\n  // mount subscriptions\n  useEffect(() => {\n    // hide on keyboard input\n    keymap.bind(\"*\", resetTimer);\n\n    // show/hiding\n    document.body.addEventListener(\"touchstart\", resetTimer);\n    document.body.addEventListener(\"mousemove\", resetTimer);\n    playback.on(\"play\", resetTimer);\n\n    playback.on(\"pause\", () => {\n      clearTimeout(timer.current);\n      setVisible(true);\n    });\n\n    playback.on(\"stop\", () => {\n      clearTimeout(timer.current);\n      setVisible(true);\n    });\n\n    document.body.addEventListener(\"mouseleave\", () => {\n      if (playback.paused) return;\n      setVisible(false);\n    });\n  }, [keymap, playback, resetTimer]);\n\n  const classNames = [\"rp-controls\", \"lv-controls\"];\n  if (!visible) classNames.push(\"hidden\");\n\n  return (\n    <div className={classNames.join(\" \")}>\n      <ScrubberBar thumbs={props.thumbs} />\n      <div className=\"lv-controls-buttons\">\n        {props.controls instanceof Array ? (\n          <>\n            {Player.defaultControlsLeft}\n\n            <div className=\"lv-controls-right\">\n              {...props.controls}\n              {Player.defaultControlsRight}\n            </div>\n          </>\n        ) : (\n          props.controls\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/main/src/IdMap.tsx",
    "content": "import * as React from \"react\";\n\nimport {bind} from \"@liqvid/utils/misc\";\nimport {recursiveMap} from \"@liqvid/utils/react\";\n\ninterface Props {\n  children?: React.ReactNode;\n  map?: Record<string, unknown>;\n}\n\n/**\n * This class gives a way to automagically attach data loaded from a file as attributes on elements.\n * This is provided to facilitate the development of—and provide a standard interface for—GUI tools.\n */\nexport class IdMap extends React.PureComponent<Props> {\n  static Context = React.createContext([]);\n\n  /** IDs found within the IdMap */\n  foundIds: Set<string>;\n\n  constructor(props: Props) {\n    super(props);\n    bind(this, [\"renderContent\"]);\n\n    this.foundIds = new Set();\n  }\n\n  render() {\n    if (this.props.hasOwnProperty(\"map\")) {\n      return (\n        <IdMap.Context.Provider value={[this.foundIds, this.props.map]}>\n          {this.renderContent([this.foundIds, this.props.map])}\n        </IdMap.Context.Provider>\n      );\n    } else {\n      return (\n        <IdMap.Context.Consumer>{this.renderContent}</IdMap.Context.Consumer>\n      );\n    }\n  }\n\n  renderContent([foundIds, map]: [Set<string>, unknown]) {\n    return recursiveMap(this.props.children, (node) => {\n      const attrs = {};\n\n      if (node.props.hasOwnProperty(\"id\")) {\n        const {id} = (node as React.ReactElement<{id: string}>).props;\n        foundIds.add(id);\n        // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n        if ((map as any)[id] !== undefined)\n          // biome-ignore lint/suspicious/noExplicitAny: <explanation>\n          Object.assign(attrs, (map as any)[id]);\n      }\n\n      if (Object.keys(attrs).length === 0) {\n        return node;\n      } else {\n        return React.cloneElement(node, attrs);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "packages/main/src/Media.ts",
    "content": "import * as React from \"react\";\n\nimport {awaitMediaCanPlay, awaitMediaCanPlayThrough} from \"./utils/media\";\nimport {between, bind} from \"@liqvid/utils/misc\";\n\nimport type {Playback} from \"@liqvid/playback\";\nimport {Player} from \"./Player\";\n\ninterface Props extends React.HTMLAttributes<HTMLMediaElement> {\n  obstructCanPlay?: boolean;\n  obstructCanPlayThrough?: boolean;\n  start?: number;\n}\n\nexport class Media extends React.PureComponent<\n  Props,\n  Record<string, never>,\n  Player\n> {\n  protected playback: Playback;\n  protected player: Player;\n  protected domElement: HTMLMediaElement;\n\n  /** When the media element should start playing. */\n  start: number;\n\n  static defaultProps = {\n    obstructCanPlay: false,\n    obstructCanPlayThrough: false,\n  };\n\n  static contextType = Player.Context;\n\n  constructor(props: Props, context: Player) {\n    super(props, context);\n    this.player = context;\n    this.playback = context.playback;\n\n    // get the time right\n    this.start = this.props.start ?? 0;\n\n    bind(this, [\n      \"pause\",\n      \"play\",\n      \"onPlay\",\n      \"onRateChange\",\n      \"onSeek\",\n      \"onTimeUpdate\",\n      \"onVolumeChange\",\n      \"onDomPlay\",\n      \"onDomPause\",\n    ]);\n  }\n\n  componentDidMount() {\n    // attach event listeners\n    this.playback.on(\"pause\", this.pause);\n    this.playback.on(\"play\", this.onPlay);\n    this.playback.on(\"ratechange\", this.onRateChange);\n    this.playback.on(\"seek\", this.onSeek);\n    this.playback.on(\"seeking\", this.pause);\n    this.playback.on(\"timeupdate\", this.onTimeUpdate);\n    this.playback.on(\"volumechange\", this.onVolumeChange);\n\n    this.domElement.addEventListener(\"play\", this.onDomPlay);\n    this.domElement.addEventListener(\"pause\", this.onDomPause);\n\n    // canplay/canplaythrough events\n    if (this.props.obstructCanPlay) {\n      this.player.obstruct(\"canplay\", awaitMediaCanPlay(this.domElement));\n    }\n    if (this.props.obstructCanPlayThrough) {\n      this.player.obstruct(\n        \"canplaythrough\",\n        awaitMediaCanPlayThrough(this.domElement),\n      );\n    }\n\n    // need to call this once initially\n    this.onVolumeChange();\n\n    // progress updater?\n    /*const getBuffers = () => {\n      const ranges = this.domElement.buffered;\n\n      const buffers: [number, number][] = [];\n      for (let i = 0; i < ranges.length; ++i) {\n        if (ranges.end(i) === Infinity) continue;\n        buffers.push([ranges.start(i) * 1000 + this.start, ranges.end(i) * 1000 + this.start]);\n      }\n\n      return buffers;\n    };\n\n    const updateBuffers = () => {\n      this.player.updateBuffer(this.domElement, getBuffers());\n    };\n\n    this.player.registerBuffer(this.domElement);\n    updateBuffers();\n    this.domElement.addEventListener(\"progress\", updateBuffers);\n    // setInterval(updateBuffers, 1000);\n    // this.domElement.addEventListener('load', updateBuffers);\n    */\n  }\n\n  componentWillUnmount() {\n    this.playback.off(\"pause\", this.pause);\n    this.playback.off(\"play\", this.onPlay);\n    this.playback.off(\"ratechange\", this.onRateChange);\n    this.playback.off(\"seek\", this.onSeek);\n    this.playback.off(\"seeking\", this.pause);\n    this.playback.off(\"timeupdate\", this.onTimeUpdate);\n    this.playback.off(\"volumechange\", this.onVolumeChange);\n\n    this.domElement.removeEventListener(\"pause\", this.onDomPause);\n    this.domElement.removeEventListener(\"play\", this.onDomPlay);\n\n    // this.player.unregisterBuffer(this.domElement);\n  }\n\n  // getter\n  get end(): number {\n    return this.start + this.domElement.duration * 1000;\n  }\n\n  pause(): void {\n    if (!this.domElement.ended) {\n      this.domElement.removeEventListener(\"pause\", this.onDomPause);\n      this.domElement.pause();\n      this.domElement.addEventListener(\"pause\", this.onDomPause);\n    }\n  }\n\n  play(): Promise<void> {\n    this.domElement.removeEventListener(\"play\", this.onDomPlay);\n    const promise = this.domElement.play();\n    this.domElement.addEventListener(\"play\", this.onDomPlay);\n    return promise;\n  }\n\n  onPlay(): void {\n    this.onTimeUpdate(this.playback.currentTime);\n  }\n\n  onRateChange(): void {\n    this.domElement.playbackRate = this.playback.playbackRate;\n  }\n\n  onSeek(t: number): void {\n    this.domElement.currentTime = (t - this.start) / 1000;\n\n    if (between(this.start, t, this.end)) {\n      if (\n        this.domElement.paused &&\n        !this.playback.paused &&\n        !this.playback.seeking\n      ) {\n        this.play().catch(this.playback.pause);\n      }\n    } else {\n      if (!this.domElement.paused) this.pause();\n    }\n  }\n\n  onTimeUpdate(t: number): void {\n    if (between(this.start, t, this.end)) {\n      if (!this.domElement.paused) return;\n\n      this.domElement.currentTime = (t - this.start) / 1000;\n      this.play().catch(this.playback.pause);\n    } else {\n      if (!this.domElement.paused) this.pause();\n      this.domElement.currentTime = (t - this.start) / 1000;\n    }\n  }\n\n  onVolumeChange(): void {\n    this.domElement.volume = this.playback.volume;\n    this.domElement.muted = this.playback.muted;\n  }\n\n  onDomPlay(): void {\n    if (this.playback.paused) {\n      this.playback.off(\"play\", this.onPlay);\n      this.playback.play();\n      this.playback.on(\"play\", this.onPlay);\n    }\n  }\n\n  onDomPause(): void {\n    if (\n      !this.playback.seeking &&\n      !this.playback.paused &&\n      !hasEnded(this.domElement)\n    ) {\n      this.playback.off(\"pause\", this.pause);\n      this.playback.pause();\n      this.playback.on(\"pause\", this.pause);\n    }\n  }\n}\n\n/**\n * Guess whether a media element has ended.\n * (`paused` fires before `ended`, and `currentTime` may be >100ms\n * behind `duration` when this happens).\n * @param media Media element to check.\n * @param threshold How far from the end of the media should be considered \"ended\".\n * @returns Whether the media element has reached its end.\n */\nfunction hasEnded(media: HTMLMediaElement, threshold = 0.5): boolean {\n  return media.ended || media.duration - media.currentTime < threshold;\n}\n"
  },
  {
    "path": "packages/main/src/Player.tsx",
    "content": "import * as React from \"react\";\nimport {EventEmitter} from \"events\";\nimport type StrictEventEmitter from \"strict-event-emitter-types\";\n\nimport CaptionsDisplay from \"./CaptionsDisplay\";\nimport {Keymap} from \"@liqvid/keymap\";\nimport {Playback} from \"./playback\";\nimport {PlaybackContext} from \"@liqvid/playback/react\";\nimport {KeymapContext} from \"@liqvid/keymap/react\";\nimport {Script} from \"./script\";\n\nimport Controls from \"./Controls\";\nimport {Captions} from \"./controls/Captions\";\nimport {FullScreen} from \"./controls/FullScreen\";\nimport {PlayPause} from \"./controls/PlayPause\";\nimport type {ThumbData} from \"./controls/ScrubberBar\";\nimport {Settings} from \"./controls/Settings\";\nimport {TimeDisplay} from \"./controls/TimeDisplay\";\nimport {Volume} from \"./controls/Volume\";\nimport {bind} from \"@liqvid/utils/misc\";\nimport {anyHover} from \"@liqvid/utils/interaction\";\nimport {createUniqueContext} from \"@liqvid/utils/react\";\n\ninterface PlayerEvents {\n  canplay: void;\n  canplaythrough: void;\n  canvasClick: void;\n}\n\ninterface Props extends React.HTMLAttributes<HTMLDivElement> {\n  controls?: JSX.Element | JSX.Element[];\n  playback?: Playback;\n  script?: Script;\n  thumbs?: ThumbData;\n}\n\nconst allowScroll = Symbol();\nconst ignoreCanvasClick = Symbol();\n\nexport class Player extends React.PureComponent<Props> {\n  /**\n   * Liqvid analogue of the [`canplay`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplay_event) event.\n   * This can be used to wait for Audio or Video files to load. You can also use {@link obstruct} to add custom loaders.\n   */\n  canPlay: Promise<void>;\n\n  /**\n   * Liqvid analogue of the [`canplaythrough`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplaythrough_event) event.\n   * This can be used to wait for Audio or Video files to load. You can also use {@link obstruct} to add custom loaders.\n   */\n  canPlayThrough: Promise<void>;\n\n  /** The {@link HTMLDivElement `<div>`} where content is attached (separate from controls). */\n  canvas: HTMLDivElement;\n\n  /** Whether keyboard controls are currently being handled. */\n  captureKeys: boolean;\n\n  hub: StrictEventEmitter<EventEmitter, PlayerEvents>;\n\n  /** {@link Keymap} attached to the player */\n  keymap: Keymap;\n\n  /** {@link Playback} attached to the player */\n  playback: Playback;\n\n  /** {@link Script} attached to the player */\n  script: Script;\n\n  buffers: Map<HTMLMediaElement, [number, number][]>;\n\n  private __canPlayTasks: Promise<unknown>[];\n  private __canPlayThroughTasks: Promise<unknown>[];\n\n  private dag: DAGLeaf;\n\n  /** {@link React.Context} used to access ambient Player */\n  static Context = createUniqueContext<Player>(\"@liqvid/player\", null);\n\n  /**\n   * Symbol to access the {@link Player} instance attached to a DOM element\n   *\n   * `player.canvas.parentElement[Player.symbol] === player`\n   */\n  static symbol = Symbol.for(\"@liqvid/player/element\");\n\n  /** Default controls appearing on the left */\n  static defaultControlsLeft = (\n    <>\n      <PlayPause />\n      <Volume />\n      <TimeDisplay />\n    </>\n  );\n\n  /** Default controls appearing on the right */\n  static defaultControlsRight = (\n    <>\n      <Captions />\n      <Settings />\n      <FullScreen />\n    </>\n  );\n\n  static defaultProps = {\n    controls: (\n      <>\n        {Player.defaultControlsLeft}\n\n        <div className=\"lv-controls-right\">{Player.defaultControlsRight}</div>\n      </>\n    ),\n    style: {},\n  };\n\n  constructor(props: Props) {\n    super(props);\n    this.hub = new EventEmitter() as StrictEventEmitter<\n      EventEmitter,\n      PlayerEvents\n    >;\n    this.__canPlayTasks = [];\n    this.__canPlayThroughTasks = [];\n\n    this.keymap = new Keymap();\n    this.captureKeys = true;\n\n    if (props.script) {\n      this.script = props.script;\n      this.playback = this.script.playback;\n    } else {\n      this.playback = props.playback;\n    }\n\n    this.buffers = new Map();\n\n    bind(this, [\n      \"onMouseUp\",\n      \"suspendKeyCapture\",\n      \"resumeKeyCapture\",\n      \"reparseTree\",\n    ]);\n    this.updateTree = this.updateTree.bind(this);\n  }\n\n  componentDidMount() {\n    const element = this.canvas.parentElement;\n    // biome-ignore lint/suspicious/noExplicitAny: symbol\n    (element as any)[Player.symbol] = this;\n\n    // inline or frame?\n    // const client =\n    //   element.parentElement.nodeName.toLowerCase() === \"main\" &&\n    //   element.parentElement.parentElement === document.body &&\n    //   element.parentElement.childNodes.length === 1;\n    // document.documentElement.classList.toggle(\"lv-frame\", client);\n    // element.classList.toggle(\"lv-frame\", client);\n\n    // keyboard events\n    document.body.addEventListener(\"keydown\", (e) => {\n      if (!this.captureKeys || document.activeElement !== document.body) return;\n      this.keymap.handle(e);\n    });\n\n    // prevent scroll on mobile\n    // document.addEventListener(\"touchmove\", e => {\n    //   if (e[allowScroll]) return;\n    //   e.preventDefault();\n    // }, {passive: false});\n    // document.addEventListener(\"touchforcechange\", e => e.preventDefault(), {passive: false});\n\n    // canPlay events --- mostly unused\n    this.canPlay = Promise.all(this.__canPlayTasks).then(() => {\n      this.hub.emit(\"canplay\");\n    });\n\n    this.canPlayThrough = Promise.all(this.__canPlayThroughTasks).then(() => {\n      this.hub.emit(\"canplaythrough\");\n    });\n\n    // hiding stuff\n    if (this.script) {\n      this.dag = toposort(this.canvas, this.script.markerNumberOf);\n\n      this.script.on(\"markerupdate\", this.updateTree);\n      this.updateTree();\n    }\n  }\n\n  private updateTree(): void {\n    const {script} = this;\n\n    recurse(this.dag);\n\n    /** Hide element */\n    function hide(leaf: DAGLeaf): void {\n      leaf.element.style.opacity = \"0\";\n      leaf.element.style.pointerEvents = \"none\";\n      leaf.element.setAttribute(\"aria-hidden\", \"true\");\n    }\n\n    /** Show element */\n    function show(leaf: DAGLeaf): void {\n      leaf.element.style.removeProperty(\"opacity\");\n      leaf.element.style.removeProperty(\"pointer-events\");\n      leaf.element.removeAttribute(\"aria-hidden\");\n      return leaf.children.forEach(recurse);\n    }\n\n    /** Recurse through DAG */\n    function recurse(leaf: DAGLeaf): void {\n      if (typeof leaf.first !== \"undefined\") {\n        if (\n          leaf.first <= script.markerIndex &&\n          (!leaf.last || script.markerIndex < leaf.last)\n        ) {\n          return show(leaf);\n        }\n\n        hide(leaf);\n      } else if (typeof leaf.during !== \"undefined\") {\n        if (script.markerName.startsWith(leaf.during)) {\n          return show(leaf);\n        }\n\n        return hide(leaf);\n      } else {\n        return leaf.children.forEach(recurse);\n      }\n    }\n  }\n\n  private canvasClick(): void {\n    const allow = this.hub.listeners(\"canvasClick\").every((_) => _() ?? true);\n    if (allow) {\n      this.playback.paused ? this.playback.play() : this.playback.pause();\n    }\n\n    this.hub.emit(\"canvasClick\");\n  }\n\n  onMouseUp(e: React.MouseEvent<HTMLDivElement>): void {\n    // ignore clicks on input tags\n    if (\n      [\"a\", \"area\", \"button\", \"input\", \"option\", \"select\", \"textarea\"].includes(\n        (e.target as Element).nodeName.toLowerCase(),\n      )\n    )\n      return;\n\n    // data-affords markup\n    if ((e.target as Element)?.closest(`*[data-affords~=\"click\"]`)) {\n      return;\n    }\n\n    // the reason for this escape hatch is that this gets called in between an element's onMouseUp\n    // listener and the listener added by dragHelper, so you can't call stopPropagation() in the\n    // onMouseUp or else the dragging won't release.\n    // biome-ignore lint/suspicious/noExplicitAny: symbol\n    if ((e.nativeEvent as any)[ignoreCanvasClick]) return;\n\n    this.canvasClick();\n  }\n\n  static allowScroll(e: React.TouchEvent | TouchEvent): void {\n    // biome-ignore lint/suspicious/noExplicitAny: symbol\n    ((\"nativeEvent\" in e ? e.nativeEvent : e) as any)[allowScroll] = true;\n  }\n\n  /**\n   * Prevent canvas clicks from pausing the video.\n   * @param e Click event on video canvas\n   * @deprecated Use data-affords=\"click\" instead\n   */\n  static preventCanvasClick(e: MouseEvent | React.MouseEvent): void {\n    // biome-ignore lint/suspicious/noExplicitAny: symbol\n    ((\"nativeEvent\" in e ? e.nativeEvent : e) as any)[ignoreCanvasClick] = true;\n  }\n\n  /** Suspends keyboard controls so that components can receive keyboard input. */\n  suspendKeyCapture(): void {\n    this.captureKeys = false;\n  }\n\n  /** Resumes keyboard controls. */\n  resumeKeyCapture(): void {\n    this.captureKeys = true;\n  }\n\n  /** @deprecated */\n  ready(): void {\n    console.info(\".ready() is a noop in v2.1\");\n  }\n\n  /**\n   * Reparse a section of the document for `during()` and `from()`\n   * @param node Element to reparse\n   */\n  reparseTree(node: HTMLElement | SVGElement): void {\n    const root = findClosest(node, this.dag);\n    if (!root) {\n      throw new Error(\"Could not find node in tree\");\n    }\n    root.children = toposort(root.element, this.script.markerNumberOf).children;\n\n    this.updateTree();\n  }\n\n  registerBuffer(elt: HTMLMediaElement): void {\n    this.buffers.set(elt, []);\n  }\n\n  unregisterBuffer(elt: HTMLMediaElement): void {\n    this.buffers.delete(elt);\n  }\n\n  updateBuffer(elt: HTMLMediaElement, buffers: [number, number][]): void {\n    this.buffers.set(elt, buffers);\n    this.playback.emit(\"bufferupdate\");\n  }\n\n  /**\n   * Obstruct {@link canPlay} or {@link canPlayThrough} events\n   * @param event Which event type to obstruct\n   * @param task Promise to append\n   */\n  obstruct(event: \"canplay\" | \"canplaythrough\", task: Promise<unknown>): void {\n    if (event === \"canplay\") {\n      this.__canPlayTasks.push(task);\n    } else {\n      this.__canPlayThroughTasks.push(task);\n    }\n  }\n\n  render() {\n    const attrs = {\n      style: this.props.style,\n    };\n    const canvasAttrs = anyHover ? {onMouseUp: this.onMouseUp} : {};\n\n    const classNames = [\"lv-player\", \"ractive-player\"];\n\n    return (\n      <Player.Context.Provider value={this}>\n        <PlaybackContext.Provider value={this.playback}>\n          <KeymapContext.Provider value={this.keymap}>\n            <div className={classNames.join(\" \")} {...attrs}>\n              <div\n                className=\"rp-canvas lv-canvas\"\n                {...canvasAttrs}\n                ref={(canvas) => (this.canvas = canvas)}\n              >\n                {this.props.children}\n              </div>\n              <CaptionsDisplay />\n              <Controls\n                controls={this.props.controls}\n                thumbs={this.props.thumbs}\n              />\n            </div>\n          </KeymapContext.Provider>\n        </PlaybackContext.Provider>\n      </Player.Context.Provider>\n    );\n  }\n}\n\ninterface DAGLeaf {\n  children: DAGLeaf[];\n  element: HTMLElement | SVGElement;\n  during?: string;\n  first?: number;\n  last?: number;\n}\n\n/* topological sort */\nfunction toposort(\n  root: HTMLElement | SVGElement,\n  mn: (markerName: string) => number,\n): DAGLeaf {\n  const nodes = Array.from(\n    root.querySelectorAll(\"*[data-from-first], *[data-during]\"),\n  ) as (HTMLElement | SVGElement)[];\n\n  const dag: DAGLeaf = {children: [], element: root};\n  const path: DAGLeaf[] = [dag];\n\n  for (const node of nodes) {\n    // get first and last marker\n    let firstMarkerName, lastMarkerName, during;\n\n    if (node.dataset.fromFirst) {\n      firstMarkerName = node.dataset.fromFirst;\n      lastMarkerName = node.dataset.fromLast;\n    } else if (node.dataset.during) {\n      during = node.dataset.during;\n    }\n\n    // CSS hides this initially, take over now\n    node.style.opacity = \"0\";\n    node.style.pointerEvents = \"none\";\n\n    // node.removeAttribute(\"data-from-first\");\n    // node.removeAttribute(\"data-from-last\");\n    // node.removeAttribute(\"data-from-during\");\n\n    // build the leaf\n    const leaf: DAGLeaf = {\n      children: [],\n      element: node,\n    };\n    if (during) leaf.during = during;\n    if (firstMarkerName) leaf.first = mn(firstMarkerName);\n    if (lastMarkerName) leaf.last = mn(lastMarkerName);\n\n    // figure out where to graft it\n    let current = path[path.length - 1];\n\n    while (!current.element.contains(node)) {\n      path.pop();\n      current = path[path.length - 1];\n    }\n\n    current.children.push(leaf);\n    path.push(leaf);\n  }\n\n  return dag;\n}\n\n/**\n * Find element's closest ancestor in DAG\n * @param needle Element to find\n * @param haystack DAG leaf to search\n * @returns Closest ancestor\n */\nfunction findClosest(\n  needle: HTMLElement | SVGElement,\n  haystack: DAGLeaf,\n): DAGLeaf {\n  if (!haystack.element.contains(needle)) {\n    return null;\n  }\n  for (let i = 0; i < haystack.children.length; ++i) {\n    if (haystack.children[i].element.contains(needle)) {\n      return findClosest(needle, haystack.children[i]) ?? haystack;\n    }\n  }\n  return haystack;\n}\n"
  },
  {
    "path": "packages/main/src/Video.tsx",
    "content": "import * as React from \"react\";\n\nimport {Media} from \"./Media\";\n\n/** Liqvid equivalent of {@link HTMLVideoElement `<video>`}. */\nexport class Video extends Media {\n  /** The underlying <video> element. */\n  declare domElement: HTMLVideoElement;\n\n  // render method\n  render() {\n    const {start, children, obstructCanPlay, obstructCanPlayThrough, ...attrs} =\n      this.props;\n\n    return (\n      <video\n        playsInline\n        preload=\"auto\"\n        ref={(node) => (this.domElement = node)}\n        {...attrs}\n      >\n        {children}\n      </video>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/main/src/controls/Captions.tsx",
    "content": "import {onClick} from \"@liqvid/utils/react\";\nimport * as React from \"react\";\nimport {useCallback, useEffect, useMemo, useState} from \"react\";\nimport {useKeymap, usePlayer} from \"../hooks\";\n\n/** Captions control. */\nexport function Captions() {\n  const player = usePlayer();\n  const keymap = useKeymap();\n  const [visible, setVisible] = useState(false);\n\n  const toggleCaptions = useCallback(\n    (\n      e:\n        | KeyboardEvent\n        | React.MouseEvent<HTMLButtonElement>\n        | React.TouchEvent<HTMLButtonElement>,\n    ) => {\n      player.canvas.parentElement.classList.toggle(\"lv-captions\");\n\n      // blur or keyboard controls will get snagged\n      if (e.currentTarget instanceof HTMLButtonElement) e.currentTarget.blur();\n    },\n    // note that player.canvas may not have loaded yet\n    [player.canvas],\n  );\n\n  useEffect(() => {\n    // visibility\n    setVisible(!!player.canvas.querySelector(\"track\"));\n\n    // keyboard shortcut\n    keymap.bind(\"C\", toggleCaptions);\n\n    return () => {\n      keymap.unbind(\"C\", toggleCaptions);\n    };\n  }, [keymap, player.canvas, toggleCaptions]);\n\n  const events = useMemo(() => onClick(toggleCaptions), [toggleCaptions]);\n\n  const style: React.CSSProperties = useMemo(\n    () => (visible ? {} : {display: \"none\"}),\n    [visible],\n  );\n\n  return (\n    <button\n      className=\"lv-controls-captions\"\n      {...events}\n      {...{style}}\n      title=\"Captions (c)\"\n    >\n      <svg viewBox=\"0 0 36 36\">\n        <path d=\"M 6.00014 8.00002 C 4.33815 8.00002 2.99981 8.8919 2.99981 9.99989 L 2.99981 25.9999 C 2.99981 27.1079 4.33815 27.9998 6.00014 27.9998 L 30.0002 27.9998 C 31.6622 27.9998 33 27.1079 33 25.9999 L 33 9.99989 C 33 8.8919 31.6622 8.00002 30.0002 8.00002 L 6.00014 8.00002 Z M 14.4032 14.0389 C 15.33 14.0389 16.0827 14.3128 16.6615 14.8606 C 17.006 15.1844 17.2644 15.6495 17.4366 16.2558 L 15.9225 16.6176 C 15.833 16.2248 15.6452 15.9148 15.3592 15.6874 C 15.0768 15.46 14.7322 15.3463 14.3257 15.3463 C 13.7642 15.3463 13.3077 15.5479 12.9563 15.9509 C 12.6083 16.354 12.4344 17.0069 12.4344 17.9095 C 12.4344 18.8672 12.6066 19.5493 12.9511 19.9559 C 13.2956 20.3624 13.7435 20.5656 14.2947 20.5656 C 14.7012 20.5656 15.0509 20.4365 15.3437 20.1781 C 15.6366 19.9197 15.8467 19.5132 15.9742 18.9585 L 17.4573 19.4288 C 17.2299 20.2556 16.851 20.8705 16.3204 21.2736 C 15.7933 21.6732 15.1233 21.8731 14.3102 21.8731 C 13.3043 21.8731 12.4774 21.5303 11.8298 20.8447 C 11.1821 20.1557 10.8582 19.2152 10.8582 18.0232 C 10.8582 16.7623 11.1838 15.7839 11.8349 15.0879 C 12.486 14.3886 13.3422 14.0389 14.4032 14.0389 Z M 22.0462 14.0389 C 22.9729 14.0389 23.7257 14.3128 24.3044 14.8606 C 24.6489 15.1844 24.9073 15.6495 25.0796 16.2558 L 23.5655 16.6176 C 23.4759 16.2248 23.2881 15.9148 23.0022 15.6874 C 22.7197 15.46 22.3752 15.3463 21.9687 15.3463 C 21.4071 15.3463 20.9506 15.5479 20.5992 15.9509 C 20.2513 16.354 20.0773 17.0069 20.0773 17.9095 C 20.0773 18.8672 20.2496 19.5493 20.5941 19.9559 C 20.9386 20.3624 21.3864 20.5656 21.9377 20.5656 C 22.3442 20.5656 22.6938 20.4365 22.9867 20.1781 C 23.2795 19.9197 23.4897 19.5132 23.6171 18.9585 L 25.1002 19.4288 C 24.8729 20.2556 24.4939 20.8705 23.9634 21.2736 C 23.4363 21.6732 22.7662 21.8731 21.9532 21.8731 C 20.9472 21.8731 20.1204 21.5303 19.4727 20.8447 C 18.825 20.1557 18.5012 19.2152 18.5012 18.0232 C 18.5012 16.7623 18.8267 15.7839 19.4779 15.0879 C 20.129 14.3886 20.9851 14.0389 22.0462 14.0389 Z\" />\n      </svg>\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/main/src/controls/FullScreen.tsx",
    "content": "import * as React from \"react\";\nimport {useEffect} from \"react\";\nimport {\n  exitFullScreen,\n  isFullScreen,\n  onFullScreenChange,\n  requestFullScreen,\n} from \"../fake-fullscreen\";\nimport {strings} from \"../i18n\";\nimport {onClick, useForceUpdate} from \"@liqvid/utils/react\";\nimport {useKeymap} from \"@liqvid/keymap/react\";\n\nconst toggleFullScreen = () =>\n  isFullScreen() ? exitFullScreen() : requestFullScreen();\nconst events = onClick(toggleFullScreen);\n\n/** Fullscreen control */\nexport function FullScreen() {\n  const keymap = useKeymap();\n  const forceUpdate = useForceUpdate();\n\n  useEffect(() => {\n    // listener\n    onFullScreenChange(forceUpdate);\n\n    // keyboard shortcut\n    keymap.bind(\"F\", toggleFullScreen);\n\n    return () => {\n      keymap.unbind(\"F\", toggleFullScreen);\n    };\n  }, [forceUpdate, keymap]);\n\n  const full = isFullScreen();\n  const label =\n    (full ? strings.EXIT_FULL_SCREEN : strings.ENTER_FULL_SCREEN) + \" (f)\";\n\n  return (\n    <button\n      className=\"lv-controls-fullscreen\"\n      aria-label={label}\n      title={label}\n      {...events}\n    >\n      <svg viewBox=\"0 0 36 36\">\n        {full ? exitFullScreenIcon : enterFullScreenIcon}\n      </svg>\n    </button>\n  );\n}\n\n/** Icon to exit full screen */\nconst exitFullScreenIcon = (\n  <>\n    <path fill=\"white\" d=\"M 14 14 h -4 v 2 h 6 v -6 h -2 v 4 z\" />\n    <path fill=\"white\" d=\"M 22 14 v -4 h -2 v 6 h 6 v -2 h -4 z\" />\n    <path fill=\"white\" d=\"M 20 26 h 2 v -4 h 4 v -2 h -6 v 6 z\" />\n    <path fill=\"white\" d=\"M 10 22 h 4 v 4 h 2 v -6 h -6 v 2 z\" />\n  </>\n);\n\n/** Icon to enter full screen */\nconst enterFullScreenIcon = (\n  <>\n    <path fill=\"white\" d=\"M 10 16 h 2 v -4 h 4 v -2 h -6 v 6 z\" />\n    <path fill=\"white\" d=\"M 20 10 v 2 h 4 v 4 h 2 v -6 h -6 z\" />\n    <path fill=\"white\" d=\"M 24 24 h -4 v 2 h 6 v -6 h -2 v 4 z\" />\n    <path fill=\"white\" d=\"M 12 20 h -2 v 6 h 6 v -2 h -4 v -4 z\" />\n  </>\n);\n"
  },
  {
    "path": "packages/main/src/controls/PlayPause.tsx",
    "content": "import {useKeymap} from \"@liqvid/keymap/react\";\nimport {usePlayback} from \"@liqvid/playback/react\";\nimport {onClick, useForceUpdate} from \"@liqvid/utils/react\";\nimport * as React from \"react\";\nimport {useEffect, useMemo} from \"react\";\nimport {strings} from \"../i18n\";\n\n/** Control for playing/pausing */\nexport function PlayPause() {\n  const keymap = useKeymap();\n  const playback = usePlayback();\n  const forceUpdate = useForceUpdate();\n  useEffect(() => {\n    // subscribe to events\n    const events = [\"pause\", \"play\", \"seeking\", \"seeked\", \"stop\"] as const;\n\n    for (const e of events)\n      playback.on(e, () => {\n        forceUpdate();\n      });\n\n    // keyboard controls\n    const toggle = () => playback[playback.paused ? \"play\" : \"pause\"]();\n    keymap.bind(\"K\", toggle);\n    keymap.bind(\"Space\", () => {\n      toggle();\n    });\n\n    return () => {\n      // unbind playback listeners\n      for (const e of events) playback.off(e, forceUpdate);\n\n      // unbind keyboard controls\n      keymap.unbind(\"K\", toggle);\n      keymap.unbind(\"Space\", toggle);\n    };\n  }, [forceUpdate, keymap, playback]);\n\n  // event handler\n  const events = useMemo(\n    () => onClick(() => (playback.paused ? playback.play() : playback.pause())),\n    [playback],\n  );\n  const label =\n    (playback.paused || playback.seeking ? strings.PLAY : strings.PAUSE) +\n    \" (k)\";\n\n  return (\n    <button\n      className=\"lv-controls-playpause\"\n      aria-label={label}\n      title={label}\n      {...events}\n    >\n      <svg viewBox=\"0 0 36 36\">\n        {playback.paused || playback.seeking ? playIcon : pauseIcon}\n      </svg>\n    </button>\n  );\n}\n\n/** Play icon */\nconst playIcon = (\n  <path\n    d=\"M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z\"\n    fill=\"white\"\n  />\n);\n\n/** Pause icon */\nconst pauseIcon = (\n  <path d=\"M 12 26 h 4 v -16 h -4 z M 21 26 h 4 v -16 h -4 z\" fill=\"white\" />\n);\n"
  },
  {
    "path": "packages/main/src/controls/ScrubberBar.tsx",
    "content": "import * as React from \"react\";\nimport {useCallback, useEffect, useMemo, useRef, useState} from \"react\";\n\nimport {ThumbData, ThumbnailBox} from \"./ThumbnailBox\";\n\nimport {anyHover, onDrag} from \"@liqvid/utils/interaction\";\nimport {between, clamp} from \"@liqvid/utils/misc\";\nimport {captureRef} from \"@liqvid/utils/react\";\nimport {useKeymap, usePlayback, useScript} from \"../hooks\";\n\nexport {ThumbData};\n\nexport function ScrubberBar(props: {thumbs: ThumbData}) {\n  const keymap = useKeymap();\n  const playback = usePlayback();\n  const script = useScript();\n\n  const [progress, setProgress] = useState({\n    scrubber: playback.currentTime / playback.duration,\n    thumb: playback.currentTime / playback.duration,\n  });\n  const [showThumb, setShowThumb] = useState(false);\n\n  // refs\n  const scrubberBar = useRef<HTMLDivElement>();\n\n  /* Event handlers */\n  const seek = useCallback(() => {\n    if (playback.seeking) return;\n    const progress = playback.currentTime / playback.duration;\n    setProgress({scrubber: progress, thumb: progress});\n  }, [playback]);\n\n  const seeked = useCallback(() => {\n    const progress = playback.currentTime / playback.duration;\n    setProgress((prev) => ({scrubber: progress, thumb: prev.thumb}));\n  }, [playback]);\n\n  const timeupdate = useCallback(() => {\n    const progress = playback.currentTime / playback.duration;\n    setProgress((prev) => ({scrubber: progress, thumb: prev.thumb}));\n  }, [playback]);\n\n  const back5 = useCallback(\n    () => playback.seek(playback.currentTime - 5000),\n    [playback],\n  );\n  const fwd5 = useCallback(\n    () => playback.seek(playback.currentTime + 5000),\n    [playback],\n  );\n  const back10 = useCallback(\n    () => playback.seek(playback.currentTime - 10000),\n    [playback],\n  );\n  const fwd10 = useCallback(\n    () => playback.seek(playback.currentTime + 10000),\n    [playback],\n  );\n\n  const seekPercent = useCallback(\n    (e: KeyboardEvent) => {\n      const num = parseInt(e.key, 10);\n      if (!isNaN(num)) {\n        playback.seek((playback.duration * num) / 10);\n      }\n    },\n    [playback],\n  );\n\n  useEffect(() => {\n    /* playback listeners */\n    playback.on(\"seek\", seek);\n    playback.on(\"seeked\", seeked);\n    playback.on(\"timeupdate\", timeupdate);\n\n    /* keyboard shortcuts */\n    // seek 5\n    keymap.bind(\"ArrowLeft\", back5);\n    keymap.bind(\"ArrowRight\", fwd5);\n\n    // seek 10\n    keymap.bind(\"J\", back10);\n    keymap.bind(\"L\", fwd10);\n\n    // percentage seeking\n    keymap.bind(\"0,1,2,3,4,5,6,7,8,9\", seekPercent);\n\n    // seek by marker\n    if (script) {\n      keymap.bind(\"W\", script.back);\n      keymap.bind(\"E\", script.forward);\n    }\n\n    return () => {\n      playback.off(\"seek\", seek);\n      playback.off(\"seeked\", seeked);\n      playback.off(\"timeupdate\", timeupdate);\n\n      keymap.unbind(\"ArrowLeft\", back5);\n      keymap.unbind(\"ArrowRight\", fwd5);\n      keymap.unbind(\"J\", back10);\n      keymap.unbind(\"L\", fwd10);\n\n      keymap.unbind(\"0,1,2,3,4,5,6,7,8,9\", seekPercent);\n\n      if (script) {\n        keymap.unbind(\"W\", script.back);\n        keymap.unbind(\"E\", script.forward);\n      }\n    };\n  }, [\n    back10,\n    back5,\n    fwd10,\n    fwd5,\n    keymap,\n    playback,\n    script,\n    seek,\n    seekPercent,\n    seeked,\n    timeupdate,\n  ]);\n\n  // event handlers\n  const divEvents = useMemo(() => {\n    if (!anyHover) return {};\n    const listener = onDrag(\n      // move\n      (e, {x}) => {\n        const rect = scrubberBar.current.getBoundingClientRect(),\n          progress = clamp(0, (x - rect.left) / rect.width, 1);\n\n        setProgress({scrubber: progress, thumb: progress});\n        playback.seek(progress * playback.duration);\n      },\n      // down\n      (e: MouseEvent) => {\n        playback.seeking = true;\n\n        const rect = scrubberBar.current.getBoundingClientRect(),\n          progress = clamp(0, (e.clientX - rect.left) / rect.width, 1);\n\n        setProgress({scrubber: progress, thumb: progress});\n        playback.seek(progress * playback.duration);\n      },\n      // up\n      () => (playback.seeking = false),\n    );\n    return {\n      onMouseDown: (e: React.MouseEvent) => listener(e.nativeEvent),\n    };\n  }, [playback]);\n\n  // events to attach on the wrapper\n  const wrapEvents = useMemo(() => {\n    const props = {} as React.HTMLAttributes<HTMLDivElement> &\n      React.RefAttributes<HTMLDivElement>;\n\n    if (anyHover) {\n      Object.assign(props, {\n        // show thumb preview on hover\n        onMouseOver: () => setShowThumb(true),\n        onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => {\n          const rect = scrubberBar.current.getBoundingClientRect(),\n            progress = clamp(0, (e.clientX - rect.left) / rect.width, 1);\n\n          setProgress((prev) => ({scrubber: prev.scrubber, thumb: progress}));\n        },\n        onMouseOut: () => setShowThumb(false),\n      });\n    }\n\n    const listener = onDrag(\n      // move\n      (e, {x}) => {\n        const rect = scrubberBar.current.getBoundingClientRect(),\n          progress = clamp(0, (x - rect.left) / rect.width, 1);\n\n        setProgress({scrubber: progress, thumb: progress});\n      },\n      // start\n      (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        playback.seeking = true;\n        setShowThumb(true);\n      },\n      // end\n      (e: TouchEvent, {x}: {x: number}) => {\n        e.preventDefault();\n        const rect = scrubberBar.current.getBoundingClientRect(),\n          progress = clamp(0, (x - rect.left) / rect.width, 1);\n\n        setShowThumb(false);\n        playback.seeking = false;\n        playback.seek(progress * playback.duration);\n      },\n    );\n\n    props.ref = captureRef((ref: HTMLDivElement) => {\n      ref.addEventListener(\"touchstart\", listener, {passive: false});\n    });\n\n    return props;\n  }, [playback]);\n\n  // events to be attached to the scrubber\n  const scrubberEvents = useMemo(() => {\n    // if (anyHover) return {};\n\n    const listener = onDrag(\n      // move\n      (e, {x}) => {\n        const rect = scrubberBar.current.getBoundingClientRect(),\n          progress = clamp(0, (x - rect.left) / rect.width, 1);\n\n        setProgress({scrubber: progress, thumb: progress});\n      },\n      // start\n      (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        playback.seeking = true;\n        setShowThumb(true);\n      },\n      // end\n      (e, {x}) => {\n        e.preventDefault();\n        const rect = scrubberBar.current.getBoundingClientRect(),\n          progress = clamp(0, (x - rect.left) / rect.width, 1);\n\n        setShowThumb(false);\n        playback.seeking = false;\n        playback.seek(progress * playback.duration);\n      },\n    );\n\n    return {\n      ref: captureRef((ref: SVGSVGElement) => {\n        ref.addEventListener(\"touchstart\", listener, {passive: false});\n      }),\n    };\n  }, [playback]);\n\n  const highlights = (props.thumbs && props.thumbs.highlights) || [];\n  const activeHighlight = highlights.find((_) =>\n    between(\n      _.time / playback.duration,\n      progress.thumb,\n      _.time / playback.duration + 0.01,\n    ),\n  );\n  const thumbTitle = activeHighlight ? activeHighlight.title : null;\n\n  return (\n    <div className=\"lv-controls-scrub\" ref={scrubberBar} {...divEvents}>\n      {props.thumbs && (\n        <ThumbnailBox\n          {...props.thumbs}\n          progress={progress.thumb}\n          show={showThumb}\n          title={thumbTitle}\n        />\n      )}\n\n      <div className=\"lv-controls-scrub-wrap\" {...wrapEvents}>\n        <svg\n          className=\"lv-controls-scrub-progress\"\n          preserveAspectRatio=\"none\"\n          viewBox=\"0 0 100 10\"\n        >\n          <rect\n            className=\"lv-progress-elapsed\"\n            x=\"0\"\n            y=\"0\"\n            height=\"10\"\n            width={progress.scrubber * 100}\n          />\n          <rect\n            className=\"lv-progress-remaining\"\n            x={progress.scrubber * 100}\n            y=\"0\"\n            height=\"10\"\n            width={(1 - progress.scrubber) * 100}\n          />\n\n          {/*ranges.map(([start, end]) => (\n            <rect\n              key={`${start}-${end}`} className=\"controls-progress-buffered\"\n              x={start / playback.duration * 100} y=\"0\" height=\"10\" width={(end - start) / playback.duration * 100}/>\n          ))*/}\n\n          {highlights.map(({time}) => (\n            <rect\n              key={time}\n              className={[\"lv-thumb-highlight\"]\n                .concat(time <= playback.currentTime ? \"past\" : [])\n                .join(\" \")}\n              x={(time / playback.duration) * 100}\n              y=\"0\"\n              width=\"1\"\n              height=\"10\"\n            />\n          ))}\n        </svg>\n        <svg\n          className=\"lv-scrubber\"\n          style={{left: `calc(${progress.scrubber * 100}% - 6px)`}}\n          viewBox=\"0 0 100 100\"\n          {...scrubberEvents}\n        >\n          <circle cx=\"50\" cy=\"50\" r=\"50\" stroke=\"none\" />\n        </svg>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/main/src/controls/Settings.tsx",
    "content": "import {clamp} from \"@liqvid/utils/misc\";\nimport * as React from \"react\";\nimport {useEffect, useMemo, useRef, useState} from \"react\";\nimport {usePlayer} from \"../hooks\";\nimport {onClick, useForceUpdate} from \"@liqvid/utils/react\";\n\nexport const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];\n\nenum Dialogs {\n  None,\n  Main,\n  Speed,\n  Captions,\n}\n\n/** Settings menu */\nexport function Settings() {\n  const player = usePlayer(),\n    {keymap, playback} = player;\n\n  const [dialog, setDialog] = useState<Dialogs>(Dialogs.None);\n  const [currentRate, setRate] = useState(playback.playbackRate);\n  const forceUpdate = useForceUpdate();\n\n  useEffect(() => {\n    const ratechange = () => setRate(playback.playbackRate);\n    const canvasClick = () => setDialog(Dialogs.None);\n\n    const slowDown = () =>\n      (playback.playbackRate = get(\n        PLAYBACK_RATES,\n        PLAYBACK_RATES.indexOf(playback.playbackRate) - 1,\n      ));\n    const speedUp = () =>\n      (playback.playbackRate = get(\n        PLAYBACK_RATES,\n        PLAYBACK_RATES.indexOf(playback.playbackRate) + 1,\n      ));\n\n    // subscribe\n    playback.on(\"ratechange\", ratechange);\n    player.hub.on(\"canvasClick\", canvasClick);\n\n    // keyboard shortcuts\n    keymap.bind(\"Shift+<\", slowDown);\n    keymap.bind(\"Shift+>\", speedUp);\n\n    return () => {\n      playback.off(\"ratechange\", ratechange);\n      player.hub.off(\"canvasClick\", canvasClick);\n\n      keymap.unbind(\"Shift+<\", slowDown);\n      keymap.unbind(\"Shift+>\", speedUp);\n    };\n  }, [keymap, playback, player.hub]);\n\n  /* handlers */\n  const setSpeed = useMemo(() => {\n    // biome-ignore lint/suspicious/noExplicitAny: ReturnType<typeof onClick> not working for some reason\n    const map: Record<number, any> = {};\n    for (const rate of PLAYBACK_RATES) {\n      map[rate] = onClick(() => {\n        playback.playbackRate = rate;\n        setDialog(Dialogs.Main);\n      });\n    }\n    return map;\n  }, [playback]);\n  const toggle = useMemo(\n    () =>\n      onClick(() =>\n        setDialog((prev) =>\n          prev === Dialogs.None ? Dialogs.Main : Dialogs.None,\n        ),\n      ),\n    [],\n  );\n\n  // const toggleSubtitles = useMemo(() => onClick(() => {\n  //   document.body.classList.toggle(\"lv-captions\");\n  //   forceUpdate();\n  // }), []);\n\n  // event handlers\n  const openMain = onClick(() => setDialog(Dialogs.Main));\n  const openSpeed = onClick(() => setDialog(Dialogs.Speed));\n  const openCaptions = onClick(() => setDialog(Dialogs.Captions));\n\n  // styles\n  const dialogStyle = useMemo(\n    () => ({\n      display: dialog === Dialogs.Main ? \"block\" : \"none\",\n    }),\n    [dialog],\n  );\n  const speedDialogStyle = useMemo(\n    () => ({\n      display: dialog === Dialogs.Speed ? \"block\" : \"none\",\n    }),\n    [dialog],\n  );\n  const captionDialogStyle = useMemo(\n    () => ({\n      display: dialog === Dialogs.Captions ? \"block\" : \"none\",\n    }),\n    [dialog],\n  );\n\n  // captions, ugh\n  const mainAudio = useRef<HTMLAudioElement>();\n  useEffect(() => {\n    mainAudio.current = getMainAudio(player.canvas);\n    if (mainAudio.current) {\n      tracks.current = captionsAndSubtitles(mainAudio.current);\n    }\n  }, [player.canvas]);\n  const tracks = useRef<TextTrack[]>([]);\n  const selectedTrack = tracks.current.find((t) => t.mode === \"showing\");\n  const setTrack = useMemo(\n    () =>\n      onClick<HTMLElement>((e) => {\n        // get index, this is kind of ugly\n        let i = -1;\n        let temp = e.currentTarget as Element;\n        while ((temp = temp.previousElementSibling)) i++;\n\n        // hide old tracks\n        for (let j = 0; j < tracks.current.length; ++j) {\n          if (j !== i) {\n            // this is absurd but necessary to dispatch cuechange???\n            tracks.current[j].mode = \"disabled\";\n            tracks.current[j].mode = \"hidden\";\n            tracks.current[j].mode = \"disabled\";\n          }\n        }\n\n        // activate new track\n        if (i >= 0) tracks.current[i].mode = \"showing\";\n\n        // refresh\n        forceUpdate();\n      }),\n    [forceUpdate],\n  );\n\n  return (\n    <div className=\"lv-controls-settings\">\n      <div className=\"lv-settings-speed-dialog\" style={speedDialogStyle}>\n        <span className=\"lv-dialog-subtitle\" {...openMain}>\n          &lt; Speed\n        </span>\n        <ul>\n          {PLAYBACK_RATES.map((rate) => (\n            <li\n              className={rate === currentRate ? \"selected\" : \"\"}\n              key={rate}\n              {...setSpeed[rate]}\n            >\n              {rate === 1 ? \"Normal\" : rate.toString()}\n            </li>\n          ))}\n        </ul>\n      </div>\n      <div className=\"lv-settings-captions-dialog\" style={captionDialogStyle}>\n        <span className=\"lv-dialog-subtitle\" {...openMain}>\n          &lt; Captions\n        </span>\n        <ul>\n          <li className={selectedTrack ? \"\" : \"selected\"} {...setTrack}>\n            Off\n          </li>\n          {tracks.current.map((track) => (\n            <li\n              className={track === selectedTrack ? \"selected\" : \"\"}\n              key={track.id || track.label || track.language}\n              {...setTrack}\n            >\n              {trackLabel(track)}\n            </li>\n          ))}\n        </ul>\n      </div>\n      <div className=\"lv-settings-dialog\" style={dialogStyle}>\n        <table>\n          <tbody>\n            <tr {...openSpeed}>\n              <th scope=\"row\">Speed</th>\n              <td>{currentRate === 1 ? \"Normal\" : currentRate} &gt;</td>\n            </tr>\n            {tracks.current.length > 0 && (\n              <tr {...openCaptions}>\n                <th scope=\"row\">Subtitles ({tracks.current.length})</th>\n                <td>{trackLabel(selectedTrack)} &gt;</td>\n              </tr>\n            )}\n          </tbody>\n        </table>\n      </div>\n      <svg {...toggle} viewBox=\"0 0 48 48\">\n        <path\n          fill=\"#FFF\"\n          d=\"m24.04 0.14285c-1.376 0-2.7263 0.12375-4.0386 0.34741l-0.64 6.7853c-1.3572 0.37831-2.6417 0.90728-3.8432 1.585l-5.244-4.3317c-2.2152 1.5679-4.1541 3.4955-5.7217 5.7101l4.3426 5.2437c-0.67695 1.2001-1.2177 2.4878-1.5959 3.8432l-6.7745 0.64053c-0.22379 1.3127-0.34741 2.6622-0.34741 4.0386 0 1.3788 0.12285 2.7238 0.34741 4.0386l6.7745 0.64056c0.37825 1.3554 0.91896 2.6431 1.5959 3.8432l-4.3317 5.2437c1.5648 2.2089 3.4908 4.1457 5.6997 5.7105l5.2545-4.3426c1.2023 0.67835 2.485 1.2174 3.8432 1.5959l0.64053 6.7853c1.3123 0.22368 2.6626 0.33658 4.0386 0.33658s2.7155-0.11289 4.0278-0.33658l0.64053-6.7853c1.3582-0.37847 2.6409-0.91755 3.8432-1.5959l5.2545 4.3426c2.2088-1.5649 4.1348-3.5017 5.6997-5.7105l-4.3317-5.2437c0.67695-1.2001 1.2177-2.4878 1.5959-3.8432l6.7744-0.64056c0.22456-1.3148 0.34741-2.6598 0.34741-4.0386 0-1.3765-0.12361-2.726-0.34741-4.0386l-6.7744-0.64053c-0.37825-1.3554-0.91896-2.6431-1.5959-3.8432l4.3426-5.2437c-1.568-2.2146-3.507-4.1422-5.722-5.7101l-5.2437 4.3317c-1.2015-0.67776-2.486-1.2067-3.8432-1.585l-0.641-6.7853c-1.3123-0.22366-2.6518-0.34741-4.0278-0.34741zm0 14.776c5.0178 0 9.076 4.0691 9.076 9.0869s-4.0582 9.0869-9.076 9.0869-9.0869-4.0691-9.0869-9.0869 4.0691-9.0869 9.0869-9.0869z\"\n        />\n      </svg>\n    </div>\n  );\n}\n\nfunction getMainAudio(elt: HTMLDivElement): HTMLAudioElement {\n  for (const audio of Array.from(elt.querySelectorAll(\"audio\"))) {\n    if (captionsAndSubtitles(audio).length > 0) return audio;\n  }\n}\n\nfunction trackLabel(track?: TextTrack): string {\n  if (track === undefined) return \"Off\";\n  return track.label || track.language;\n}\n\nfunction captionsAndSubtitles(audio: HTMLAudioElement): TextTrack[] {\n  return Array.from(audio.textTracks).filter((t) =>\n    [\"captions\", \"subtitles\"].includes(t.kind),\n  );\n}\n\nfunction get<T>(arr: T[], i: number): T {\n  return arr[clamp(0, i, arr.length - 1)];\n}\n"
  },
  {
    "path": "packages/main/src/controls/ThumbnailBox.tsx",
    "content": "import * as React from \"react\";\nimport {useEffect} from \"react\";\n\nimport {usePlayer} from \"../hooks\";\nimport {formatTime} from \"@liqvid/utils/time\";\n\nexport interface ThumbData {\n  /**\n   * Number of columns per thumbnail sheet.\n   * @default 5\n   */\n  cols?: number;\n\n  /**\n   * Number of rows per thumbnail sheet.\n   * @default 5\n   */\n  rows?: number;\n\n  /**\n   * Width of individual thumbnails.\n   * @default 160\n   */\n  width?: number;\n\n  /**\n   * Height of individual thumbnails.\n   * @default 100\n   */\n  height?: number;\n\n  /**\n   * How many seconds between thumbnails.\n   * @default 4\n   */\n  frequency?: number;\n\n  /** URL pattern for thumbnails. Must include \"%s\". */\n  path: string;\n\n  /** Points of interest in the video to highlight. */\n  highlights?: VideoHighlight[];\n}\n\ninterface Props extends Omit<ThumbData, \"highlights\"> {\n  progress: number;\n  show: boolean;\n  title: string;\n}\n\ninterface VideoHighlight {\n  time: number;\n  title: string;\n}\n\nexport function ThumbnailBox(props: Props) {\n  const player = usePlayer(),\n    {playback} = player;\n\n  const {\n    cols = 5,\n    rows = 5,\n    frequency = 4,\n    path,\n    progress,\n    show,\n    title,\n    height = 100,\n    width = 160,\n  } = props;\n  const count = cols * rows;\n\n  useEffect(() => {\n    // preload thumbs (once more important loading has taken place)\n    const maxSlide = Math.floor(playback.duration / frequency / 1000),\n      maxSheet = Math.floor(maxSlide / count);\n\n    player.hub.on(\"canplay\", () => {\n      for (let sheetNum = 0; sheetNum <= maxSheet; ++sheetNum) {\n        const img = new Image();\n        img.src = path.replace(\"%s\", sheetNum.toString());\n      }\n    });\n  }, [count, frequency, path, playback.duration, player]);\n\n  const time = (progress * playback.duration) / 1000,\n    markerNum = Math.floor(time / frequency),\n    sheetNum = Math.floor(markerNum / count),\n    markerNumOnSheet = markerNum % count,\n    row = Math.floor(markerNumOnSheet / rows),\n    col = markerNumOnSheet % rows;\n\n  const sheetName = path.replace(\"%s\", sheetNum.toString());\n\n  return (\n    <div\n      className=\"lv-controls-thumbnail\"\n      style={{\n        display: show ? \"block\" : \"none\",\n        left: `calc(${progress * 100}%)`,\n      }}\n    >\n      {title && <span className=\"lv-thumbnail-title\">{title}</span>}\n      <div className=\"lv-thumbnail-box\">\n        <img\n          src={sheetName}\n          style={{\n            left: `-${col * width}px`,\n            top: `-${row * height}px`,\n          }}\n        />\n        <span className=\"lv-thumbnail-time\">{formatTime(time * 1000)}</span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/main/src/controls/TimeDisplay.tsx",
    "content": "import {formatTime} from \"@liqvid/utils/time\";\nimport * as React from \"react\";\nimport {useEffect} from \"react\";\nimport {usePlayback} from \"../hooks\";\nimport {useForceUpdate} from \"@liqvid/utils/react\";\n\nexport function TimeDisplay() {\n  const playback = usePlayback();\n  const forceUpdate = useForceUpdate();\n\n  useEffect(() => {\n    playback.on(\"durationchange\", forceUpdate);\n    playback.on(\"seek\", forceUpdate);\n    playback.on(\"timeupdate\", forceUpdate);\n\n    return () => {\n      playback.off(\"durationchange\", forceUpdate);\n      playback.off(\"seek\", forceUpdate);\n      playback.off(\"timeupdate\", forceUpdate);\n    };\n  }, [forceUpdate, playback]);\n\n  return (\n    <span className=\"lv-controls-time\">\n      <span className=\"lv-current-time\">\n        {formatTime(playback.currentTime)}\n      </span>\n      <span className=\"lv-time-separator\">/</span>\n      <span className=\"lv-total-time\">{formatTime(playback.duration)}</span>\n    </span>\n  );\n}\n"
  },
  {
    "path": "packages/main/src/controls/Volume.tsx",
    "content": "import {onClick, useForceUpdate} from \"@liqvid/utils/react\";\nimport * as React from \"react\";\nimport {useCallback, useEffect, useMemo} from \"react\";\nimport {useKeymap, usePlayback} from \"../hooks\";\nimport {strings} from \"../i18n\";\n\n/** Volume control */\nexport function Volume() {\n  const keymap = useKeymap();\n  const playback = usePlayback();\n  const forceUpdate = useForceUpdate();\n\n  // keyboard controls\n  const incrementVolume = useCallback(\n    () => (playback.volume = playback.volume + 0.05),\n    [playback],\n  );\n  const decrementVolume = useCallback(\n    () => (playback.volume = playback.volume - 0.05),\n    [playback],\n  );\n  const toggleMute = useCallback(\n    () => (playback.muted = !playback.muted),\n    [playback],\n  );\n\n  useEffect(() => {\n    playback.on(\"volumechange\", forceUpdate);\n    keymap.bind(\"ArrowUp\", incrementVolume);\n    keymap.bind(\"ArrowDown\", decrementVolume);\n    keymap.bind(\"M\", toggleMute);\n\n    return () => {\n      playback.off(\"volumechange\", forceUpdate);\n      keymap.unbind(\"ArrowUp\", incrementVolume);\n      keymap.unbind(\"ArrowDown\", decrementVolume);\n      keymap.unbind(\"M\", toggleMute);\n    };\n  }, [\n    decrementVolume,\n    forceUpdate,\n    incrementVolume,\n    keymap,\n    playback,\n    toggleMute,\n  ]);\n\n  // input\n  const onChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      playback.muted = false;\n      playback.volume = parseFloat(e.target.value) / 100;\n    },\n    [playback],\n  );\n\n  const events = useMemo(\n    () => onClick(() => (playback.muted = !playback.muted)),\n    [playback],\n  );\n  const label = (playback.muted ? strings.UNMUTE : strings.MUTE) + \" (m)\";\n\n  const volumeText =\n    new Intl.NumberFormat(undefined, {style: \"percent\"}).format(\n      playback.volume,\n    ) + \" volume\";\n\n  return (\n    <div className=\"lv-controls-volume\">\n      <button aria-label={label} title={label}>\n        <svg {...events} viewBox=\"0 0 100 100\">\n          {speakerIcon}\n          {playback.muted ? (\n            mutedIcon\n          ) : (\n            <g>\n              {playback.volume > 0 && waveIcon1}\n              {playback.volume >= 0.5 && waveIcon2}\n            </g>\n          )}\n        </svg>\n      </button>\n      <input\n        aria-valuetext={volumeText}\n        min=\"0\"\n        max=\"100\"\n        onChange={onChange}\n        type=\"range\"\n        value={playback.muted ? 0 : playback.volume * 100}\n      />\n    </div>\n  );\n}\n\nconst speakerIcon = (\n  <path\n    d=\"M 10 35 h 20 l 25 -20 v 65 l -25 -20 h -20 z\"\n    fill=\"white\"\n    stroke=\"none\"\n  />\n);\nconst mutedIcon = (\n  <path d=\"M 63 55 l 20 20 m 0 -20 l -20 20\" stroke=\"white\" strokeWidth=\"7\" />\n);\nconst waveIcon1 = (\n  <path d=\"M 62 32.5 a 1,1 0 0,1 0,30\" fill=\"white\" stroke=\"none\" />\n);\nconst waveIcon2 = (\n  <path\n    d=\"M 62 15 a 1,1 0 0,1 0,65 v -10 a 10,10 0 0,0 0,-45 v -10 z\"\n    fill=\"white\"\n    stroke=\"none\"\n  />\n);\n"
  },
  {
    "path": "packages/main/src/fake-fullscreen.ts",
    "content": "import {\n  fullscreenEnabled,\n  requestFullScreen as $requestFullScreen,\n  exitFullScreen as $exitFullScreen,\n  isFullScreen as $isFullScreen,\n  onFullScreenChange as $onFullScreenChange,\n} from \"./polyfills\";\n\nlet __isFullScreen = false;\nconst __callbacks: (() => void)[] = [];\n\nexport const requestFullScreen = fullscreenEnabled\n  ? $requestFullScreen\n  : (): void => {\n      window.parent.postMessage(\n        {type: \"fake-fullscreen\", value: true},\n        window.parent.origin,\n      );\n\n      if (!__isFullScreen) {\n        __isFullScreen = true;\n        for (const _ of __callbacks) _();\n      }\n    };\n\nexport const exitFullScreen = fullscreenEnabled\n  ? $exitFullScreen\n  : (): void => {\n      window.parent.postMessage(\n        {type: \"fake-fullscreen\", value: false},\n        window.parent.origin,\n      );\n\n      if (__isFullScreen) {\n        __isFullScreen = false;\n        for (const _ of __callbacks) _();\n      }\n    };\n\nexport const isFullScreen = fullscreenEnabled\n  ? $isFullScreen\n  : (): boolean => {\n      return __isFullScreen;\n    };\n\nexport const onFullScreenChange = fullscreenEnabled\n  ? $onFullScreenChange\n  : (callback: () => void): void => {\n      __callbacks.push(callback);\n    };\n"
  },
  {
    "path": "packages/main/src/hooks.ts",
    "content": "import {usePlayback} from \"@liqvid/playback/react\";\nimport {useContext, useEffect} from \"react\";\n\nexport {KeymapContext, useKeymap} from \"@liqvid/keymap/react\";\nexport {PlaybackContext, usePlayback, useTime} from \"@liqvid/playback/react\";\n\nimport {Player} from \"./Player\";\nimport type {Script} from \"./script\";\n\n/** Access the ambient {@link Player} */\nexport function usePlayer(): Player {\n  return useContext(Player.Context);\n}\n\n/** Register a callback for when the marker changes */\nexport function useMarkerUpdate(\n  callback: (prevIndex: number) => void,\n  deps?: React.DependencyList,\n): void {\n  const script = useScript();\n\n  useEffect(() => {\n    script.on(\"markerupdate\", callback);\n\n    return () => {\n      script.off(\"markerupdate\", callback);\n    };\n  }, [callback, script, ...deps]);\n}\n\n/** Access the ambient {@link Script} */\nexport function useScript<M extends string = string>(): Script<M> {\n  return usePlayer().script as Script<M>;\n}\n\n/** Register a callback for when the time changes */\nexport function useTimeUpdate(\n  callback: (t: number) => void,\n  deps?: React.DependencyList,\n): void {\n  const playback = usePlayback();\n\n  useEffect(() => {\n    playback.on(\"seek\", callback);\n    playback.on(\"timeupdate\", callback);\n\n    return () => {\n      playback.off(\"seek\", callback);\n      playback.off(\"timeupdate\", callback);\n    };\n  }, [callback, playback, ...deps]);\n}\n"
  },
  {
    "path": "packages/main/src/i18n.ts",
    "content": "export const strings = {\n  EXIT_FULL_SCREEN: \"Exit full screen\",\n  ENTER_FULL_SCREEN: \"Full screen\",\n  MUTE: \"Mute\",\n  UNMUTE: \"Unmute\",\n  PAUSE: \"Pause\",\n  PLAY: \"Play\",\n} as const;\n"
  },
  {
    "path": "packages/main/src/index.ts",
    "content": "import {Audio} from \"./Audio\";\nimport {IdMap} from \"./IdMap\";\nimport {Media} from \"./Media\";\nimport {Player} from \"./Player\";\nimport {Video} from \"./Video\";\nimport {Playback} from \"./playback\";\nimport {Script} from \"./script\";\nimport * as Utils from \"./utils\";\nexport * from \"./hooks\";\n\nexport {ReplayData} from \"@liqvid/utils/replay-data\";\n\nexport {Audio, IdMap, Media, Playback, Player, Script, Utils, Video};\n\n// backwards compatibility\nimport {Keymap as KeyMap, Keymap} from \"@liqvid/keymap\";\nexport {Keymap, KeyMap};\n\n// controls\nimport {Captions} from \"./controls/Captions\";\nimport {FullScreen} from \"./controls/FullScreen\";\nimport {PlayPause} from \"./controls/PlayPause\";\nimport {ScrubberBar} from \"./controls/ScrubberBar\";\nimport {Settings} from \"./controls/Settings\";\nimport {TimeDisplay} from \"./controls/TimeDisplay\";\nimport {Volume} from \"./controls/Volume\";\n\nexport const Controls = {\n  Captions,\n  FullScreen,\n  PlayPause,\n  ScrubberBar,\n  Settings,\n  TimeDisplay,\n  Volume,\n};\n\n// alias\nimport {isClient} from \"./utils/rsc\";\nif (isClient && !window.hasOwnProperty(\"RactivePlayer\")) {\n  Object.defineProperty(window, \"RactivePlayer\", {\n    get() {\n      if (typeof window.Liqvid !== \"undefined\") {\n        return window.Liqvid;\n      }\n    },\n  });\n}\n\n// export type\nimport type {\n  useKeymap,\n  useMarkerUpdate,\n  usePlayback,\n  usePlayer,\n  useScript,\n  useTime,\n  useTimeUpdate,\n} from \"./hooks\";\n\ninterface Liqvid {\n  Audio: typeof Audio;\n  Controls: typeof Controls;\n  IdMap: typeof IdMap;\n  Keymap: typeof Keymap;\n  Media: typeof Media;\n  Playback: typeof Playback;\n  Player: typeof Player;\n  Script: typeof Script;\n  Utils: typeof Utils;\n  Video: typeof Video;\n\n  useKeymap: typeof useKeymap;\n  useMarkerUpdate: typeof useMarkerUpdate;\n  usePlayback: typeof usePlayback;\n  usePlayer: typeof usePlayer;\n  useScript: typeof useScript;\n  useTime: typeof useTime;\n  useTimeUpdate: typeof useTimeUpdate;\n}\n\n// add to global object\ndeclare global {\n  interface Window {\n    Liqvid: Liqvid;\n\n    /** @deprecated */\n    RactivePlayer: Liqvid;\n  }\n}\n"
  },
  {
    "path": "packages/main/src/playback.ts",
    "content": "import {Playback} from \"@liqvid/playback\";\nimport {parseTime} from \"@liqvid/utils/time\";\n\n// backwards compatibility\nObject.defineProperty(Playback.prototype, \"hub\", {\n  get: function () {\n    return this;\n  },\n});\nconst seek = Playback.prototype.seek;\nPlayback.prototype.seek = function (t: number | string) {\n  if (typeof t === \"string\") {\n    t = parseTime(t);\n  }\n  seek.call(this, t);\n};\n\nexport {Playback};\n"
  },
  {
    "path": "packages/main/src/polyfills.ts",
    "content": "import {isClient} from \"./utils/rsc\";\nconst id = <T>(_: T) => _;\n\nexport const fullscreenEnabled: boolean = isClient\n  ? (\n      [\n        \"fullscreenEnabled\",\n        \"webkitFullscreenEnabled\",\n        \"mozFullScreenEnabled\",\n        \"msFullscreenEnabled\",\n      ] as const\n    )\n      // biome-ignore lint/suspicious/noExplicitAny: vendor-specific\n      .map((_) => (document as any)[_])\n      .concat(false)\n      .find((_) => _ !== undefined)\n  : false;\n\nexport const requestFullScreen: () => Promise<void> = isClient\n  ? [\n      \"requestFullscreen\",\n      \"webkitRequestFullscreen\",\n      \"mozRequestFullScreen\",\n      \"msRequestFullscreen\",\n    ]\n      // biome-ignore lint/suspicious/noExplicitAny: vendor-specific\n      .map((_) => (document as any).body[_])\n      .concat(() => {})\n      .find(id)\n      .bind(document.body)\n  : async () => {};\n\nexport const exitFullScreen: () => Promise<void> = isClient\n  ? (\n      [\n        \"exitFullscreen\",\n        \"webkitExitFullscreen\",\n        \"mozCancelFullScreen\",\n        \"msExitFullscreen\",\n      ] as const\n    )\n      // biome-ignore lint/suspicious/noExplicitAny: vendor-specific\n      .map((_) => (document as any)[_])\n      .concat(async () => {})\n      .find(id)\n      .bind(document)\n  : async () => {};\n\nexport const isFullScreen = () =>\n  ([\"fullscreen\", \"webkitIsFullScreen\", \"mozFullScreen\"] as const)\n    // biome-ignore lint/suspicious/noExplicitAny: vendor-specific\n    .map((_) => (document as any)[_] as boolean)\n    .find((_) => _ !== undefined);\n\nexport function onFullScreenChange(callback: EventListener): void {\n  for (const event of [\n    \"fullscreenchange\",\n    \"webkitfullscreenchange\",\n    \"mozfullscreenchange\",\n    \"MSFullscreenChange\",\n  ] as const)\n    document.addEventListener(event, callback);\n}\n"
  },
  {
    "path": "packages/main/src/script.ts",
    "content": "import {EventEmitter} from \"events\";\nimport {between, bind} from \"@liqvid/utils/misc\";\nimport {parseTime, timeRegexp} from \"@liqvid/utils/time\";\nimport type StrictEventEmitter from \"strict-event-emitter-types\";\n\nimport {Playback} from \"./playback\";\n\nexport type Marker<M extends string = string> = [M, number, number];\n\ninterface ScriptEvents {\n  markerupdate: number;\n}\n\nexport class Script<\n  M extends string = string,\n> extends (EventEmitter as new () => StrictEventEmitter<\n  EventEmitter,\n  ScriptEvents\n>) {\n  /** The underlying {@link Playback} instance. */\n  playback: Playback;\n\n  /** The array of markers, in the form [name, startTime, endTime]. */\n  markers: Marker<M>[];\n\n  /** Index of the active marker. */\n  markerIndex: number;\n\n  constructor(\n    markers: readonly (\n      | readonly [M, number | string, number | string]\n      | readonly [M, number | string]\n    )[],\n  ) {\n    super();\n    this.setMaxListeners(0);\n\n    // bind methods\n    bind(this, [\n      \"back\",\n      \"forward\",\n      \"markerByName\",\n      \"markerNumberOf\",\n      \"parseStart\",\n      \"parseEnd\",\n      \"__updateMarker\",\n    ]);\n\n    // parse times\n    let time = 0;\n    this.markers = [];\n\n    for (const marker of markers) {\n      if (marker.length === 2) {\n        const [, duration] = marker;\n\n        this.markers.push([\n          marker[0],\n          time,\n          time +\n            (typeof duration === \"string\" ? parseTime(duration) : duration),\n        ]);\n      } else {\n        const [, begin, end] = marker;\n\n        this.markers.push([\n          marker[0],\n          typeof begin === \"string\" ? parseTime(begin) : begin,\n          typeof end === \"string\" ? parseTime(end) : end,\n        ]);\n      }\n\n      time = this.markers[this.markers.length - 1][2];\n    }\n\n    this.markerIndex = 0;\n\n    // create playback object\n    this.playback = new Playback({\n      duration: this.markers[this.markers.length - 1][2],\n    });\n\n    this.playback.on(\"seek\", this.__updateMarker);\n    this.playback.on(\"timeupdate\", this.__updateMarker);\n  }\n\n  /** @deprecated */\n  get hub(): this {\n    return this;\n  }\n\n  /** Name of the active marker. */\n  get markerName(): string {\n    return this.markers[this.markerIndex][0];\n  }\n\n  // public methods\n\n  /** Seek playback to the previous marker. */\n  back(): void {\n    this.playback.seek(this.markers[Math.max(0, this.markerIndex - 1)][1]);\n  }\n\n  /** Advance playback to the next marker. */\n  forward(): void {\n    this.playback.seek(\n      this.markers[Math.min(this.markers.length - 1, this.markerIndex + 1)][1],\n    );\n  }\n\n  /**\n   * Returns the first marker with the given name.\n   * @throws {Error} If no marker named `name` exists.\n   */\n  markerByName(name: string): Marker {\n    return this.markers[this.markerNumberOf(name)];\n  }\n\n  /**\n   * Returns the first index of a marker named `name`.\n   * @throws {Error} If no marker named `name` exists.\n   */\n  markerNumberOf(name: string): number {\n    for (let i = 0; i < this.markers.length; ++i) {\n      if (this.markers[i][0] === name) return i;\n    }\n    throw new Error(`Marker ${name} does not exist`);\n  }\n\n  /** If `start` is a string, returns the starting time of the marker with that name. Otherwise, returns `start`. */\n  parseStart(start: number | string): number {\n    if (typeof start === \"string\") {\n      if (start.match(timeRegexp)) return parseTime(start);\n      else return this.markerByName(start)[1];\n    } else {\n      return start;\n    }\n  }\n\n  /** If `end` is a string, returns the ending time of the marker with that name. Otherwise, returns `end`. */\n  parseEnd(end: number | string): number {\n    if (typeof end === \"string\") {\n      if (end.match(timeRegexp)) return parseTime(end);\n      else return this.markerByName(end)[2];\n    } else {\n      return end;\n    }\n  }\n\n  /** Update marker */\n  __updateMarker(t: number): void {\n    let newIndex;\n    for (let i = 0; i < this.markers.length; ++i) {\n      const [, begin, end] = this.markers[i];\n      if (between(begin, t, end)) {\n        newIndex = i;\n        break;\n      }\n    }\n\n    if (newIndex === undefined) newIndex = this.markers.length - 1;\n\n    if (newIndex !== this.markerIndex) {\n      const prevIndex = this.markerIndex;\n      this.markerIndex = newIndex;\n      this.emit(\"markerupdate\", prevIndex);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/main/src/utils/authoring.ts",
    "content": "// conditional display\nexport function showIf(cond: boolean): {style?: React.CSSProperties} {\n  if (!cond)\n    return {\n      style: {\n        opacity: 0,\n        pointerEvents: \"none\",\n      },\n    };\n  return {};\n}\n\n/** Returns a CSS block to show the element only when marker name begins with `prefix` */\nexport function during(prefix: string) {\n  return {\n    [\"data-during\"]: prefix,\n  };\n}\n\n/** Returns a CSS block to show the element when marker is in [first, last) */\nexport function from(first: string, last?: string) {\n  return {\n    [\"data-from-first\"]: first,\n    [\"data-from-last\"]: last,\n  };\n}\n"
  },
  {
    "path": "packages/main/src/utils/dom.ts",
    "content": "export function fragmentFromHTML(str: string): DocumentFragment {\n  const t = document.createElement(\"template\");\n  t.innerHTML = str;\n  return t.content.cloneNode(true) as DocumentFragment;\n}\n"
  },
  {
    "path": "packages/main/src/utils/interactivity.ts",
    "content": "import {onDrag} from \"@liqvid/utils/interaction\";\nimport {captureRef} from \"@liqvid/utils/react\";\n\ntype Move = Parameters<typeof onDrag>[0];\ntype Down = Parameters<typeof dragHelper>[1];\ntype DownArgs = Parameters<typeof onDrag>[1] extends (\n  arg0: unknown,\n  ...args: infer T\n) => void\n  ? T\n  : never;\ntype Up = Parameters<typeof onDrag>[2];\n\nfunction isReactMouseEvent<T>(\n  e: MouseEvent | React.MouseEvent<T> | React.TouchEvent<T> | TouchEvent,\n): e is React.MouseEvent<T> {\n  return \"nativeEvent\" in e && e.nativeEvent instanceof MouseEvent;\n}\n\n/**\n * Helper for implementing drag functionality, abstracting over mouse vs touch events.\n * @returns An event listener which should be added to both `mousedown` and `touchstart` events.\n */\nexport function dragHelper<T extends HTMLElement | SVGElement>(\n  move: Move,\n  /** Callback for when dragging begins (pointer is touched). */\n  down: (\n    /** The underlying `mousedown` or `touchstart` event */\n    e: MouseEvent | React.MouseEvent<T> | React.TouchEvent<T> | TouchEvent,\n    /** Information about the pointer location */\n    hit: {\n      /** Horizontal coordinate of pointer */\n      x: number;\n      /** Vertical coordinate of pointer */\n      y: number;\n    },\n    /** The upHandler used internally by this method */\n    upHandler: (e: MouseEvent | TouchEvent) => void,\n    /** The moveHandler used internally by this method */\n    moveHandler: (e: MouseEvent | TouchEvent) => void,\n  ) => void = () => {},\n  /** Callback for when dragging ends (pointer is lifted). */\n  up: Up = () => {},\n) {\n  /*\n    We can't directly use the version from @liqvid/utils/interaction because down() might want to use React types.\n    Hence this goofiness.\n  */\n  let args: DownArgs;\n  const __down: Parameters<typeof onDrag>[1] = (e, ...captureArgs) => {\n    args = captureArgs;\n  };\n  const listener = onDrag(move, __down, up);\n\n  return (\n    e: MouseEvent | React.MouseEvent<T> | React.TouchEvent<T> | TouchEvent,\n  ) => {\n    if ((e instanceof MouseEvent || isReactMouseEvent(e)) && e.button !== 0)\n      return;\n\n    if (\"nativeEvent\" in e) {\n      listener(e.nativeEvent);\n    } else {\n      listener(e);\n    }\n\n    down(e, ...args);\n  };\n}\n\n/**\n * Helper for implementing drag functionality, abstracting over mouse vs touch events.\n * @param innerRef Any `ref` that you want attached to the element, since this method attaches its own `ref` attribute. This is a hack around https://github.com/facebook/react/issues/2043.\n * @returns An object of event handlers which should be added to a React element with {...}\n */\nexport function dragHelperReact<T extends HTMLElement | SVGElement>(\n  move: Move,\n  down?: Down,\n  up?: Up,\n  innerRef?: React.Ref<T>,\n) {\n  const listener = dragHelper(move, down, up);\n\n  /* https://github.com/microsoft/TypeScript/issues/46819 */\n  type AEL = HTMLElement[\"addEventListener\"];\n\n  if (innerRef) {\n    const intercept = captureRef(\n      (ref) =>\n        (ref.addEventListener as AEL)(\"touchstart\", listener, {passive: false}),\n      innerRef,\n    );\n    return {\n      \"data-affords\": \"click\",\n      onMouseDown: listener,\n      ref: intercept,\n    };\n  } else {\n    return {\n      \"data-affords\": \"click\",\n      onMouseDown: listener,\n      onTouchStart: listener,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/main/src/utils/media.ts",
    "content": "/** Promisifed version of [canplay](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplay_event) event */\nexport function awaitMediaCanPlay(media: HTMLMediaElement): Promise<void> {\n  return new Promise((resolve) => {\n    if (media.readyState === media.HAVE_FUTURE_DATA) {\n      return resolve();\n    } else {\n      media.addEventListener(\"canplay\", () => resolve());\n    }\n  });\n}\n\n/** Promisified version of [`canplaythrough`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplaythrough_event) event. */\nexport function awaitMediaCanPlayThrough(\n  media: HTMLMediaElement,\n): Promise<void> {\n  return new Promise((resolve) => {\n    if (media.readyState === media.HAVE_ENOUGH_DATA) {\n      return resolve();\n    } else {\n      media.addEventListener(\"canplaythrough\", () => resolve());\n    }\n  });\n}\n"
  },
  {
    "path": "packages/main/src/utils/mobile.ts",
    "content": "import {anyHover} from \"@liqvid/utils/interaction\";\nimport {captureRef} from \"@liqvid/utils/react\";\n\nexport {\n  anyHover,\n  onClick as attachClickHandler,\n} from \"@liqvid/utils/interaction\";\n\n/**\n\tDrop-in replacement for onClick handlers which works better on mobile.\n  The innerRef attribute, and the implementation, is a hack around https://github.com/facebook/react/issues/2043.\n*/\nexport const onClick = <T extends HTMLElement | SVGElement>(\n  callback: (e: React.MouseEvent<T> | TouchEvent) => void,\n  innerRef?: React.Ref<T>,\n) => {\n  if (anyHover) {\n    return {onClick: callback};\n  } else {\n    let touchId: number, target: EventTarget & T;\n\n    // touchstart handler\n    const onTouchStart = (e: TouchEvent): void => {\n      if (typeof touchId === \"number\") return;\n      target = e.currentTarget as T;\n      touchId = e.changedTouches[0].identifier;\n    };\n\n    // touchend handler\n    const onTouchEnd = (e: TouchEvent): void => {\n      if (typeof touchId !== \"number\") return;\n\n      for (const touch of Array.from(e.changedTouches)) {\n        if (touch.identifier !== touchId) continue;\n\n        if (\n          target.contains(\n            document.elementFromPoint(touch.clientX, touch.clientY),\n          )\n        ) {\n          callback(e);\n        }\n\n        touchId = undefined;\n        break;\n      }\n    };\n\n    return {\n      ref: captureRef<T>((ref) => {\n        ref.addEventListener(\n          \"touchstart\",\n          onTouchStart as (e: TouchEvent) => void,\n          {passive: false},\n        );\n        ref.addEventListener(\n          \"touchend\",\n          onTouchEnd as (e: TouchEvent) => void,\n          {passive: false},\n        );\n      }, innerRef),\n    };\n  }\n};\n"
  },
  {
    "path": "packages/main/src/utils/rsc.ts",
    "content": "// work with Next.js\nexport const isClient = typeof globalThis.document !== \"undefined\";\n"
  },
  {
    "path": "packages/main/src/utils.ts",
    "content": "/* various things we sometimes use */\nexport * as animation from \"@liqvid/utils/animation\";\nexport * as authoring from \"./utils/authoring\";\n// export * as dom from './utils/dom';\nexport * as interactivity from \"./utils/interactivity\";\nexport * as json from \"@liqvid/utils/json\";\nexport * as media from \"./utils/media\";\nexport * as misc from \"@liqvid/utils/misc\";\nexport * as mobile from \"./utils/mobile\";\nexport * as react from \"@liqvid/utils/react\";\nexport * as replayData from \"@liqvid/utils/replay-data\";\nexport * as svg from \"@liqvid/utils/svg\";\nexport * as time from \"@liqvid/utils/time\";\n"
  },
  {
    "path": "packages/main/styl/controls/captions.styl",
    "content": ".lv-controls-captions,\n.rp-controls-captions\n  position relative\n\n  > svg > path\n    fill #FFF\n  \n  &::after\n    content \"\"\n    background $wine\n    border-radius 2px\n    bottom 3px\n    display block\n    height 2px\n    left 18px\n    position absolute\n    width 0\n    transition left .1s cubic-bezier(0.4,0,1,1), width .1s cubic-bezier(0.4,0,1,1)\n    \n  .lv-captions &::after,\n  .rp-captions &::after\n    width 30px\n    left 3px\n    transition left .25s cubic-bezier(0,0,0.2,1),width .25s cubic-bezier(0,0,0.2,1)\n  \n\n.lv-captions-display,\n.rp-captions-display\n  background-color rgba(0, 0, 0, .5)\n  border-radius 5px\n  color #FFF\n  display none\n  font-family sans-serif\n  font-size 1.3em\n  z-index 500\n  padding .7em\n  text-align center\n  user-select none\n  \n  position absolute\n  bottom 10%\n  left 50%\n  transform translateX(-50%)\n  width auto\n\n.lv-captions > .lv-captions-display:not(:empty),\n.rp-captions > .rp-captions-display:not(:empty)\n  display block\n\n"
  },
  {
    "path": "packages/main/styl/controls/scrubber.styl",
    "content": "/* scrubbing */\n.lv-controls-scrub,\n.rp-controls-scrub\n  -webkit-tap-highlight-color transparent\n  \n.lv-controls-scrub-wrap,\n.rp-controls-scrub-wrap\n  height 100%\n  width 100%\n\n.lv-controls-scrub-progress,\n.rp-controls-scrub-progress\n  display block\n  height 100%\n  width 100%\n  pointer-events none\n\n.lv-progress-elapsed,\n.rp-progress-elapsed\n  fill var(--lv-elapsed-color)\n\n.lv-progress-remaining,\n.rp-progress-remaining\n  fill rgba(255, 255, 255, 0.6)\n  \n.controls-progress-buffered\n  fill orange\n\n.lv-scrubber,\n.rp-scrubber\n  height 13px\n  position absolute\n  top -3px\n  \n  > circle\n    fill var(--lv-scrubber-color)\n\n.lv-thumb-highlight,\n.rp-thumb-highlight\n  fill #FFF\n  \n  &.past\n    fill #ec76b3\n"
  },
  {
    "path": "packages/main/styl/controls/settings.styl",
    "content": ".lv-controls-settings,\n.rp-controls-settings\n  position relative\n  \n  > svg\n    cursor pointer\n    height 100%\n    padding 6px\n  \n.lv-settings-dialog,\n.rp-settings-dialog\n  background-color rgba(32, 32, 32, 0.85)\n  border-radius 3px 3px 0 0\n  padding 2px 0\n  position absolute\n  bottom 42px\n  right 0\n  width 10em\n  \n  > table\n    border-collapse collapse\n    width 100%\n  \n  > table > tbody > tr\n    cursor pointer\n    font-family sans-serif\n    \n    &:hover\n      background-color rgba(72, 72, 72, 0.85)\n    \n    > th\n      padding .25em 0 .25em .5em\n      vertical-align middle\n      text-align left\n\n    > td\n      padding .25em .5em .25em 0\n      text-align right\n      vertical-align middle\n      \n.lv-settings-speed-dialog,\n.lv-settings-captions-dialog,\n.rp-settings-speed-dialog\n  background-color rgba(32, 32, 32, 0.85)\n  padding 2px 0\n  position absolute\n  bottom 42px\n  line-height 1rem\n  right 0\n  width 6em\n  \n  > ul\n    list-style-type none\n    \n    > li\n      cursor pointer\n      padding 4px .5em\n\n      &:hover\n        background-color rgba(72, 72, 72, 0.85)\n        \n      &.selected\n        color $wine\n\n.lv-dialog-subtitle,\n.rp-dialog-subtitle\n  border-bottom 1px solid #AAA\n  display block\n  text-align center\n"
  },
  {
    "path": "packages/main/styl/controls/thumbs.styl",
    "content": "$thumb-img-height = 100px\n$thumb-img-width = 160px\n$thumb-padding = 3px\n\n.lv-controls-thumbnail,\n.rp-controls-thumbnail\n  background #333\n  \n  font-size .85em\n  padding $thumb-padding\n  bottom 13px\n  text-align center\n  position absolute\n  margin-left (-($thumb-img-width / 2 + $thumb-padding))\n\n.lv-thumbnail-box,\n.rp-thumbnail-box\n  overflow hidden\n  \n  height $thumb-img-height\n  width $thumb-img-width\n  margin 0 auto\n  position relative\n  text-align center\n  \n  > img\n    position absolute\n\n.lv-thumbnail-title,\n.rp-thumbnail-title\n  color #FFF\n  font-family sans-serif\n  \n.lv-thumbnail-time,\n.rp-thumbnail-time\n  color #FFF\n  font-family sans-serif\n  position absolute\n  bottom .5em\n  text-align center\n  width 100%\n  left 0\n"
  },
  {
    "path": "packages/main/styl/controls/time.styl",
    "content": ".lv-controls-time,\n.rp-controls-time\n  display inline-block\n  font-family sans-serif\n  font-size 11px\n  line-height 36px\n  padding 0 1.5em\n  text-align center\n  user-select none\n  vertical-align top\n\n.lv-time-separator,\n.rp-time-separator\n  margin 0 3px\n"
  },
  {
    "path": "packages/main/styl/controls/volume.styl",
    "content": "range-thumb()\n  &::-webkit-slider-thumb\n    {block}\n  \n  &::-moz-range-thumb\n    {block}\n    \n  &::-ms-thumb\n    {block}\n    \nrange-track()\n  &::-webkit-slider-runnable-track\n    {block}\n\n  &::-moz-range-track\n    {block}\n    \n  &::-ms-track\n    {block}\n\n.lv-controls-volume,\n.rp-controls-volume\n  cursor pointer\n  z-index 100000\n  \n  > svg\n    height 100%\n    padding 3px\n  \n  > button > svg\n    padding 3px\n\n  > input[type=range]\n    background transparent\n    width 10em\n    height 100%\n    -webkit-appearance none\n    \n    +range-thumb()\n      -webkit-appearance none\n      height 1.1em\n      width 1.1em\n      \n      background var(--lv-volume-color)\n      border 1px solid #000\n      border-radius 1em\n      box-shadow 1px 1px 1px #000, 0px 0px 1px #0d0d0d\n      cursor pointer\n      margin-top -.5em\n      line-height 1.5em\n      vertical-align middle\n    \n    +range-track()\n      height 2px\n      cursor pointer\n      background #FFF\n      border-radius 1.3px\n      margin auto 4px\n      vertical-align middle\n      line-height 1.5em\n"
  },
  {
    "path": "packages/main/styl/liqvid.styl",
    "content": "$wine = #AF1866\n$blue = #1A69B5\n\nuser-select(value)\n  user-select value\n  -webkit-user-select value\n\n/* reset styles */\n*\n  box-sizing border-box\n  margin 0\n  padding 0\n\n*:focus\n  outline none\n  \n:link\n  color inherit\n  text-decoration none\n\niframe, img\n  border none\n\n.lv-player,\n.ractive-player\n  --lv-aspect-ratio 1.6\n  --lv-controls-height 44px\n  --lv-elapsed-color $wine\n  --lv-scrubber-color $wine\n  --lv-scrub-height 6px\n  --lv-volume-color $wine\n  --lv-buttons-height calc(var(--lv-controls-height) - var(--lv-scrub-height))\n  --lv-canvas-height calc(var(--lv-height) - var(--lv-controls-height))\n  --lv-height calc(var(--lv-width) / var(--lv-aspect-ratio))\n\n  background-color #000\n  position relative\n  height var(--lv-height)\n  width var(--lv-width)\n\n// backwards compatibility\n.ractive-player\n  --rp-controls-height var(--lv-controls-height)\n  --rp-scrub-height var(--lv-scrub-height)\n  --rp-buttons-height var(--lv-buttons-height)\n  --rp-height var(--lv-height)\n  --rp-width var(--lv-width)\n\n// frame mode\n// .lv-player.lv-frame\n.lv-player,\n.ractive-player\n  height 100%\n  width 100%\n  overflow hidden\n  position absolute\n  left 0\n  top 0\n\n.lv-canvas,\n.rp-canvas\n  background-color #FFF\n  position relative\n  user-select none\n  // height 100%\n  // width 100%\n  \n// .lv-frame > .lv-canvas\n.lv-canvas,\n.rp-canvas\n  height var(--lv-height)\n  width var(--lv-width)\n\n// yikes\n.not-ready\n  *[data-from-first], *[data-during]\n    visibility hidden\n\n@media (min-aspect-ratio: 8/5)\n  // :root.lv-frame\n  :root\n    font-size 2vh\n\n  // .lv-player.lv-frame\n  .lv-player\n    --lv-width 160vh\n\n    > .lv-canvas\n      margin 0 auto\n  \n  .ractive-player\n    --lv-width 160vh\n\n    > .rp-canvas\n      margin 0 auto\n    \n@media (max-aspect-ratio: 8/5)\n  // :root.lv-frame\n  :root\n    font-size 1.25vw\n\n  // .lv-player.lv-frame\n  .lv-player\n    --lv-width 100vw\n\n    > .lv-canvas\n      top calc((100% - 62.5vw) / 2)\n\n  .ractive-player\n    --lv-width 100vw\n\n    > .rp-canvas\n      top calc((100% - 62.5vw) / 2)\n\n/* control positioning */\n.lv-controls,\n.rp-controls\n  background-color rgba(0, 0, 0, 0.5)\n  color #FFF\n  height var(--lv-controls-height)\n  position absolute\n  bottom 0\n  left 0\n  transition opacity .25s cubic-bezier(0.0,0.0,0.2,1)\n  width 100%\n  user-select none\n  z-index 1000\n  \n  &.hidden\n    opacity 0\n    transition opacity .1s cubic-bezier(0.4, 0, 1, 1)\n\n// .lv-frame > .lv-controls\n.lv-controls,\n.rp-controls\n  top calc(50vh + var(--lv-height)/2 - var(--lv-controls-height))\n  left calc(50vw - var(--lv-width) / 2)\n  width var(--lv-width)\n\n.lv-controls-scrub,\n.rp-controls-scrub\n  cursor pointer\n  height var(--lv-scrub-height)\n  // height calc(5.5% / 0.55)\n  margin 0 auto\n  position relative\n  width 97%\n  z-index 2\n\n.lv-controls-buttons,\n.rp-controls-buttons\n  height var(--lv-buttons-height)\n  line-height var(--lv-buttons-height)\n  margin 0 auto\n  width 97%\n  \n  > *\n    display inline-block\n    height 100%\n    vertical-align top\n    \n.lv-controls-right,\n.rp-controls-right\n  float right\n  \n  > *\n    display inline-block\n    height 100%\n    vertical-align top\n\n.lv-controls-buttons,\n.lv-controls-right, .rp-controls-right\n.lv-controls-volume\n  > button\n    background none\n    border none\n    outline none\n    cursor pointer\n    opacity .9\n    transition opacity .1s cubic-bezier(0.4,0,1,1)\n    height 100%\n    padding 0\n    width 38px\n    \n    &:hover\n      opacity 1\n      transition opacity .1s cubic-bezier(0,0,0.2,1)\n      \n    > svg\n      height 100%\n\n// individual controls\n.lv-controls-playpause, .lv-controls-fullscreen,\n.rp-controls-playpause, .rp-controls-fullscreen\n  cursor pointer\n\n@import \"controls/captions\"\n@import \"controls/scrubber\"\n@import \"controls/settings\"\n@import \"controls/thumbs\"\n@import \"controls/time\"\n@import \"controls/volume\"\n\n// mobile styles\n@import \"mobile\"\n\n// hack to target Safari, which does horrible things without this line\n@media not all and (min-resolution:.001dpcm)\n  @supports (-webkit-appearance:none)\n    *[data-from-first], *[data-during]\n      will-change opacity\n"
  },
  {
    "path": "packages/main/styl/mobile.styl",
    "content": "@media (min-width: 401px)    \n  .lv-controls,\n  .rp-controls\n    --lv-controls-left 2\n    --lv-controls-right 3\n\n@media (max-width: 800px)\n  .lv-player,\n  .ractive-player\n    --lv-controls-height 36px\n\n@media (max-width: 400px)\n  .lv-player,\n  .ractive-player\n    --lv-controls-height 30px\n    \n  .lv-controls,\n  .rp-controls\n    --lv-controls-left 2\n    --lv-controls-right 2\n    \n  .lv-controls-settings,\n  .rp-controls-settings\n    display none\n\n@media (any-hover: none)\n  .lv-controls,\n  .rp-controls\n    color #FFF\n    height var(--lv-controls-height)\n    position absolute\n    top unset\n    transition opacity .25s cubic-bezier(0.0,0.0,0.2,1)\n    width var(--lv-width)\n    bottom calc((100% - var(--lv-height)) / 2)\n    user-select none\n    z-index 1000\n    left calc((100% - var(--lv-width)) / 2)\n    \n  // hide certain controls\n  .lv-controls-volume,\n  .rp-controls-volume\n    display none !important\n  \n  // time\n  .lv-controls-time,\n  .rp-controls-time\n    line-height unset\n\n  .lv-current-time, .lv-total-time,\n  .rp-current-time, .rp-total-time\n    height var(--lv-buttons-height)\n    line-height var(--lv-buttons-height)\n    position absolute\n    top 0\n    width var(--lv-buttons-height)\n    \n  .lv-current-time,\n  .rp-current-time\n    left 0\n    text-align right\n    \n  .lv-total-time,\n  .rp-total-time\n    right calc((var(--lv-controls-right) - 1) * var(--lv-buttons-height))\n    text-align left\n\n  .lv-time-separator,\n  .rp-time-separator\n    display none\n    \n  .lv-controls-scrub-wrap,\n  .rp-controls-scrub-wrap\n    position relative\n    \n    &::after\n      content \"\"\n      position absolute\n      top calc(var(--lv-buttons-height) * (0.075 / 0.55 - 0.5))\n      bottom calc(var(--lv-buttons-height) * (0.075 / 0.55 - 0.5))\n      left 0\n      right 0\n    \n  .lv-controls-scrub,\n  .rp-controls-scrub\n    height calc(7.5% / 0.55)\n    top unset\n    top calc(50% - 0.5 * (7.5% / 0.55))\n    width calc(100% - (var(--lv-controls-left) + var(--lv-controls-right)) * var(--lv-buttons-height) - 7px)\n    left calc(var(--lv-controls-left) * var(--lv-buttons-height))\n    margin unset\n    \n  .lv-scrubber,\n  .rp-scrubber\n    height 216%\n    top -50%\n    pointer-events all\n    \n  .lv-controls-playpause,\n  .rp-controls-playpause\n    cursor pointer\n    pointer-events all\n\n    position absolute\n    left var(--lv-buttons-height)\n    top 0\n    width var(--lv-buttons-height)\n\n  .lv-controls-right,\n  .rp-controls-right\n    float unset\n    position absolute\n    right 0\n    top 0\n    \n    > *\n      width var(--lv-buttons-height)\n"
  },
  {
    "path": "packages/main/tests/DocumentTimeline.mock",
    "content": "Object.defineProperty(window, 'DocumentTimeline', {\n  writable: true,\n  value: jest.fn().mockImplementation(() => ({})),\n});\n"
  },
  {
    "path": "packages/main/tests/IdMap.test.tsx",
    "content": "import * as React from \"react\";\nimport {render} from \"@testing-library/react\";\n\nimport \"./matchMedia.mock\";\nimport \"./DocumentTimeline.mock\";\n\nimport {IdMap} from \"..\";\n\nfunction Component() {\n  return (\n    <div>\n      <IdMap>\n        <h1 id=\"a\">Hello World</h1>\n      </IdMap>\n    </div>\n  );\n}\n\ndescribe(\"IdMap\", () => {\n  const objects = {\n    a: {\n      className: \"demo-a\",\n      style: {\n        color: \"red\",\n      },\n    },\n  };\n\n  test(\"root works\", () => {\n    render(\n      <IdMap map={objects}>\n        <h1 id=\"a\">Hello World</h1>\n      </IdMap>,\n    );\n    const h1 = document.querySelector(\"h1\");\n    expect(h1.className).toBe(\"demo-a\");\n    expect(h1.style.color).toBe(\"red\");\n  });\n\n  test(\"nested works\", () => {\n    render(\n      <IdMap map={objects}>\n        <Component />\n      </IdMap>,\n    );\n    const h1 = document.querySelector(\"h1\");\n    expect(h1.className).toBe(\"demo-a\");\n    expect(h1.style.color).toBe(\"red\");\n  });\n});\n"
  },
  {
    "path": "packages/main/tests/Player.test.tsx",
    "content": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"./matchMedia.mock\";\nimport \"./DocumentTimeline.mock\";\n\nimport {Playback, Player} from \"..\";\n\ndescribe(\"Player\", () => {\n  let player: Player;\n\n  const playback = new Playback({duration: 60000});\n\n  beforeEach(() => {\n    render(<Player playback={playback} ref={(ref) => (player = ref)}></Player>);\n  });\n\n  test(\"canvas\", () => {\n    expect(player.canvas).toBeInstanceOf(HTMLDivElement);\n  });\n\n  test(\"canvasClick\", () => {\n    fireEvent.mouseUp(player.canvas);\n    expect(playback.paused).toBe(false);\n    fireEvent.mouseUp(player.canvas);\n    expect(playback.paused).toBe(true);\n  });\n\n  test(\"symbol\", () => {\n    expect(player.canvas.parentElement[Player.symbol]).toBe(player);\n  });\n});\n"
  },
  {
    "path": "packages/main/tests/controls/PlayPause.test.tsx",
    "content": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\nimport \"../DocumentTimeline.mock\";\n\nimport {Playback, Player} from \"../..\";\nimport {act} from \"react-dom/test-utils\";\n\ndescribe(\"Play/pause button\", () => {\n  let button: HTMLButtonElement;\n\n  const playback = new Playback({duration: 60000});\n\n  beforeEach(() => {\n    render(<Player playback={playback}></Player>);\n    act(() => {\n      playback.pause();\n      playback.seeking = false;\n    });\n    button = document.querySelector(\".lv-controls-playpause > svg\");\n  });\n\n  test(\"Clicking button toggles\", () => {\n    fireEvent.click(button);\n    expect(playback.paused).toBe(false);\n    fireEvent.click(button);\n    expect(playback.paused).toBe(true);\n  });\n\n  test(\"Icon updates\", () => {\n    expect(button).toMatchSnapshot();\n    act(() => {\n      playback.play();\n    });\n    expect(button).toMatchSnapshot();\n    act(() => {\n      playback.seeking = true;\n    });\n    expect(button).toMatchSnapshot();\n  });\n\n  test(\"Keyboard shortcuts work\", () => {\n    fireEvent.keyDown(document.body, {key: \"K\", code: \"KeyK\"});\n    expect(playback.paused).toBe(false);\n\n    fireEvent.keyDown(document.body, {key: \"K\", code: \"KeyK\"});\n    expect(playback.paused).toBe(true);\n\n    fireEvent.keyDown(document.body, {key: \" \", code: \"Space\"});\n    expect(playback.paused).toBe(false);\n\n    fireEvent.keyDown(document.body, {key: \" \", code: \"Space\"});\n    expect(playback.paused).toBe(true);\n  });\n});\n"
  },
  {
    "path": "packages/main/tests/controls/ScrubberBar.test.tsx",
    "content": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\nimport \"../DocumentTimeline.mock\";\n\nimport {Player, Script} from \"../..\";\nimport {act} from \"react-dom/test-utils\";\n\ndescribe(\"Scrubber bar\", () => {\n  const script = new Script([\n    [\"A\", \"20\"],\n    [\"B\", \"20\"],\n    [\"C\", \"20\"],\n  ]);\n  const playback = script.playback;\n\n  beforeEach(() => {\n    playback.seek(0);\n    render(<Player script={script}></Player>);\n  });\n\n  test(\"Keyboard shortcuts work\", () => {\n    act(() => {\n      playback.seek(30000);\n    });\n    fireEvent.keyDown(document.body, {key: \"ArrowLeft\", code: \"ArrowLeft\"});\n    expect(playback.currentTime).toBe(25000);\n\n    fireEvent.keyDown(document.body, {key: \"ArrowRight\", code: \"ArrowRight\"});\n    expect(playback.currentTime).toBe(30000);\n\n    fireEvent.keyDown(document.body, {key: \"j\", code: \"KeyJ\"});\n    expect(playback.currentTime).toBe(20000);\n\n    fireEvent.keyDown(document.body, {key: \"l\", code: \"KeyL\"});\n    expect(playback.currentTime).toBe(30000);\n\n    for (let i = 0; i <= 9; ++i) {\n      fireEvent.keyDown(document.body, {key: String(i), code: `Digit${i}`});\n      expect(playback.currentTime).toBe((60000 * i) / 10);\n    }\n  });\n\n  test(\"Script keyboard shortcuts work\", () => {\n    fireEvent.keyDown(document.body, {key: \"e\", code: \"KeyE\"});\n    expect(playback.currentTime).toBe(20000);\n    fireEvent.keyDown(document.body, {key: \"e\", code: \"KeyE\"});\n    expect(playback.currentTime).toBe(40000);\n    fireEvent.keyDown(document.body, {key: \"w\", code: \"KeyW\"});\n    expect(playback.currentTime).toBe(20000);\n  });\n});\n"
  },
  {
    "path": "packages/main/tests/controls/TimeDisplay.test.tsx",
    "content": "import * as React from \"react\";\nimport {render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\nimport \"../DocumentTimeline.mock\";\n\nimport {Playback, Player} from \"../..\";\nimport {act} from \"react-dom/test-utils\";\n\ndescribe(\"Time display button\", () => {\n  let display: HTMLButtonElement;\n\n  const playback = new Playback({duration: 60000});\n\n  beforeEach(() => {\n    render(<Player playback={playback}></Player>);\n    act(() => {\n      playback.currentTime = 0;\n      playback.duration = 60000;\n    });\n    display = document.querySelector(\".lv-controls-time\");\n  });\n\n  test(\"displays correct time\", () => {\n    expect(display.textContent).toBe(\"0:00/1:00\");\n    act(() => {\n      playback.seek(30500);\n    });\n    expect(display.textContent).toBe(\"0:30/1:00\");\n  });\n\n  test(\"responds to duration changes\", () => {\n    act(() => {\n      playback.duration = 120500;\n    });\n    expect(display.textContent).toBe(\"0:00/2:00\");\n  });\n});\n"
  },
  {
    "path": "packages/main/tests/controls/Volume.test.tsx",
    "content": "import * as React from \"react\";\nimport {fireEvent, render} from \"@testing-library/react\";\n\nimport \"../matchMedia.mock\";\nimport \"../DocumentTimeline.mock\";\n\nimport {Playback, Player} from \"../..\";\nimport {act} from \"react-dom/test-utils\";\n\ndescribe(\"Volume button\", () => {\n  let button: HTMLButtonElement;\n  let slider: HTMLInputElement;\n\n  const playback = new Playback({duration: 60000});\n\n  beforeEach(() => {\n    render(<Player playback={playback}></Player>);\n    act(() => {\n      playback.muted = false;\n      playback.volume = 1;\n    });\n    button = document.querySelector(\".lv-controls-volume > button > svg\");\n    slider = document.querySelector(\".lv-controls-volume > input\");\n  });\n\n  test(\"Pressing button mutes\", () => {\n    fireEvent.click(button);\n    expect(playback.muted).toBe(true);\n    fireEvent.click(button);\n    expect(playback.muted).toBe(false);\n  });\n\n  test(\"Setting volume works\", () => {\n    fireEvent.change(slider, {target: {value: 70}});\n    expect(playback.volume).toBe(0.7);\n  });\n\n  test(\"Setting volume updates button icon\", () => {\n    expect(button).toMatchSnapshot();\n\n    fireEvent.change(slider, {target: {value: 40}});\n    expect(button).toMatchSnapshot();\n\n    fireEvent.change(slider, {target: {value: 0}});\n    expect(button).toMatchSnapshot();\n  });\n\n  test(\"Keyboard shortcuts work\", () => {\n    fireEvent.keyDown(document.body, {key: \"ArrowDown\", code: \"ArrowDown\"});\n    fireEvent.keyDown(document.body, {key: \"ArrowDown\", code: \"ArrowDown\"});\n    expect(playback.volume).toBeCloseTo(0.9, 5);\n\n    fireEvent.keyDown(document.body, {key: \"ArrowUp\", code: \"ArrowUp\"});\n    expect(playback.volume).toBeCloseTo(0.95, 5);\n\n    fireEvent.keyDown(document.body, {key: \"M\", code: \"KeyM\"});\n    expect(playback.muted).toBe(true);\n  });\n});\n"
  },
  {
    "path": "packages/main/tests/controls/__snapshots__/PlayPause.test.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Play/pause button Icon updates 1`] = `\n<svg\n  viewBox=\"0 0 36 36\"\n>\n  <path\n    d=\"M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z\"\n    fill=\"white\"\n  />\n</svg>\n`;\n\nexports[`Play/pause button Icon updates 2`] = `\n<svg\n  viewBox=\"0 0 36 36\"\n>\n  <path\n    d=\"M 12 26 h 4 v -16 h -4 z M 21 26 h 4 v -16 h -4 z\"\n    fill=\"white\"\n  />\n</svg>\n`;\n\nexports[`Play/pause button Icon updates 3`] = `\n<svg\n  viewBox=\"0 0 36 36\"\n>\n  <path\n    d=\"M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z\"\n    fill=\"white\"\n  />\n</svg>\n`;\n"
  },
  {
    "path": "packages/main/tests/controls/__snapshots__/Volume.test.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Volume button Setting volume updates button icon 1`] = `\n<svg\n  viewBox=\"0 0 100 100\"\n>\n  <path\n    d=\"M 10 35 h 20 l 25 -20 v 65 l -25 -20 h -20 z\"\n    fill=\"white\"\n    stroke=\"none\"\n  />\n  <g>\n    <path\n      d=\"M 62 32.5 a 1,1 0 0,1 0,30\"\n      fill=\"white\"\n      stroke=\"none\"\n    />\n    <path\n      d=\"M 62 15 a 1,1 0 0,1 0,65 v -10 a 10,10 0 0,0 0,-45 v -10 z\"\n      fill=\"white\"\n      stroke=\"none\"\n    />\n  </g>\n</svg>\n`;\n\nexports[`Volume button Setting volume updates button icon 2`] = `\n<svg\n  viewBox=\"0 0 100 100\"\n>\n  <path\n    d=\"M 10 35 h 20 l 25 -20 v 65 l -25 -20 h -20 z\"\n    fill=\"white\"\n    stroke=\"none\"\n  />\n  <g>\n    <path\n      d=\"M 62 32.5 a 1,1 0 0,1 0,30\"\n      fill=\"white\"\n      stroke=\"none\"\n    />\n  </g>\n</svg>\n`;\n\nexports[`Volume button Setting volume updates button icon 3`] = `\n<svg\n  viewBox=\"0 0 100 100\"\n>\n  <path\n    d=\"M 10 35 h 20 l 25 -20 v 65 l -25 -20 h -20 z\"\n    fill=\"white\"\n    stroke=\"none\"\n  />\n  <g />\n</svg>\n`;\n"
  },
  {
    "path": "packages/main/tests/hooks.test.tsx",
    "content": "import * as React from \"react\";\nimport {render} from \"@testing-library/react\";\n\nimport \"./matchMedia.mock\";\nimport \"./DocumentTimeline.mock\";\n\nimport {\n  KeyMap,\n  Playback,\n  Player,\n  Script,\n  usePlayback,\n  usePlayer,\n  useKeymap,\n  useScript,\n} from \"..\";\n\nfunction Test<T>(props: {\n  hook: () => T;\n  return: {\n    value: T;\n  };\n}): null {\n  props.return.value = props.hook();\n  return null;\n}\n\ndescribe(\"Hooks\", () => {\n  const playback = new Playback({duration: 60000});\n\n  test(\"useKeyMap\", () => {\n    const o = {value: null as KeyMap};\n    let player: Player;\n    render(\n      <Player playback={playback} ref={(ref) => (player = ref)}>\n        <Test hook={useKeymap} return={o} />\n      </Player>,\n    );\n    expect(o.value).toBe(player.keymap);\n  });\n\n  test(\"usePlayback\", () => {\n    const o = {value: null as Playback};\n    let player: Player;\n    render(\n      <Player playback={playback} ref={(ref) => (player = ref)}>\n        <Test hook={usePlayback} return={o} />\n      </Player>,\n    );\n    expect(o.value).toBe(player.playback);\n  });\n\n  test(\"usePlayer\", () => {\n    const o = {value: null as Player};\n    let player: Player;\n    render(\n      <Player playback={playback} ref={(ref) => (player = ref)}>\n        <Test hook={usePlayer} return={o} />\n      </Player>,\n    );\n    expect(o.value).toBe(player);\n  });\n\n  test(\"useScript\", () => {\n    const o = {value: null as Script};\n    let player: Player;\n    render(\n      <Player playback={playback} ref={(ref) => (player = ref)}>\n        <Test hook={useScript} return={o} />\n      </Player>,\n    );\n    expect(o.value).toBe(player.script);\n  });\n});\n"
  },
  {
    "path": "packages/main/tests/matchMedia.mock",
    "content": "Object.defineProperty(window, 'matchMedia', {\n  writable: true,\n  value: jest.fn().mockImplementation(query => ({\n    matches: true,\n    media: query,\n    onchange: null,\n    addListener: jest.fn(), // Deprecated\n    removeListener: jest.fn(), // Deprecated\n    addEventListener: jest.fn(),\n    removeEventListener: jest.fn(),\n    dispatchEvent: jest.fn(),\n  })),\n});\n"
  },
  {
    "path": "packages/main/tests/script.test.ts",
    "content": "import \"./matchMedia.mock\";\nimport \"./DocumentTimeline.mock\";\n\nimport {Playback, Script} from \"..\";\n\ndescribe(\"Script\", () => {\n  let script: Script;\n\n  beforeEach(() => {\n    script = new Script([\n      [\"A\", \"20\"],\n      [\"B\", \"20\"],\n      [\"C\", \"20\"],\n    ]);\n  });\n\n  test(\"constructor\", () => {\n    expect(script.markers).toEqual([\n      [\"A\", 0, 20000],\n      [\"B\", 20000, 40000],\n      [\"C\", 40000, 60000],\n    ]);\n    expect(script.markerIndex).toBe(0);\n    expect(script.markerName).toBe(\"A\");\n\n    expect(script.playback).toBeInstanceOf(Playback);\n    expect(script.playback.duration).toBe(60000);\n  });\n\n  test(\"constructor with numeric durations\", () => {\n    script = new Script([\n      [\"A\", 20000],\n      [\"B\", 20000],\n      [\"C\", 20000],\n    ]);\n    expect(script.markers).toEqual([\n      [\"A\", 0, 20000],\n      [\"B\", 20000, 40000],\n      [\"C\", 40000, 60000],\n    ]);\n    expect(script.markerIndex).toBe(0);\n    expect(script.markerName).toBe(\"A\");\n\n    expect(script.playback).toBeInstanceOf(Playback);\n    expect(script.playback.duration).toBe(60000);\n  });\n\n  test(\"back/forward\", () => {\n    script.forward();\n    expect(script.markerName).toBe(\"B\");\n    expect(script.playback.currentTime).toBe(20000);\n\n    script.forward();\n    expect(script.markerName).toBe(\"C\");\n    expect(script.playback.currentTime).toBe(40000);\n\n    script.forward();\n    expect(script.markerName).toBe(\"C\");\n    expect(script.playback.currentTime).toBe(40000);\n\n    script.back();\n    expect(script.markerName).toBe(\"B\");\n    expect(script.playback.currentTime).toBe(20000);\n\n    script.back();\n    expect(script.markerName).toBe(\"A\");\n    expect(script.playback.currentTime).toBe(0);\n\n    script.back();\n    expect(script.markerName).toBe(\"A\");\n    expect(script.playback.currentTime).toBe(0);\n  });\n\n  test(\"markerByName\", () => {\n    expect(script.markerByName(\"B\")).toEqual([\"B\", 20000, 40000]);\n    expect(() => script.markerByName(\"D\")).toThrow(\"Marker D does not exist\");\n  });\n\n  test(\"markerNumberOf\", () => {\n    expect(script.markerNumberOf(\"B\")).toBe(1);\n    expect(() => script.markerNumberOf(\"D\")).toThrow(\"Marker D does not exist\");\n  });\n\n  test(\"parseStart\", () => {\n    expect(script.parseStart(\"B\")).toBe(20000);\n    expect(script.parseStart(\"20.5\")).toBe(20500);\n  });\n\n  test(\"parseEnd\", () => {\n    expect(script.parseEnd(\"B\")).toBe(40000);\n    expect(script.parseEnd(\"20.5\")).toBe(20500);\n  });\n\n  test(\"responds to playback\", () => {\n    script.playback.seek(30000);\n    expect(script.markerIndex).toBe(1);\n    expect(script.markerName).toBe(\"B\");\n  });\n});\n"
  },
  {
    "path": "packages/main/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"alwaysStrict\": true,\n    \"declaration\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"incremental\": true,\n    \"jsx\": \"react\",\n    \"lib\": [\"esnext\", \"dom\"],\n    \"esModuleInterop\": true,\n    \"moduleResolution\": \"node\",\n    \"noImplicitAny\": true,\n    \"outDir\": \"./dist/esm\",\n    \"pretty\": true,\n    \"removeComments\": false,\n    \"target\": \"esnext\"\n  },\n  \"include\": [\"./src/index.ts\"]\n}\n"
  },
  {
    "path": "packages/mathjax/.gitignore",
    "content": ".DS_Store\nnode_modules\n\ndist\n"
  },
  {
    "path": "packages/mathjax/README.md",
    "content": "# @liqvid/mathjax\n\n[MathJax](https://mathjax.org/) plugin for [Liqvid](https://liqvidjs.org).\n\n## Usage\n\n```tsx\nimport {MJX} from \"@liqvid/mathjax\";\n\nfunction Quadratic() {\n  return (\n    <div>\n      The value of <MJX>x</MJX> is given by the quadratic formula\n      <MJX display>{String.raw`x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}`}</MJX>\n    </div>\n  );\n}\n```\n"
  },
  {
    "path": "packages/mathjax/package.json",
    "content": "{\n  \"name\": \"@liqvid/mathjax\",\n  \"version\": \"0.1.2\",\n  \"description\": \"MathJax integration for Liqvid\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\"\n    },\n    \"./plain\": {\n      \"import\": \"./dist/esm/plain.mjs\",\n      \"require\": \"./dist/cjs/plain.cjs\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/types/*.d.ts\"]\n    }\n  },\n  \"files\": [\"dist/*\"],\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"keywords\": [\"liqvid\", \"mathjax\"],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:js && pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc --module esnext --outDir dist/esm; tsc --module commonjs --outDir dist/cjs; node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"eslint --ext ts,tsx --fix src\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/mathjax\",\n  \"license\": \"MIT\",\n  \"peerDependencies\": {\n    \"@types/react\": \">=18.0.0\",\n    \"liqvid\": \"workspace:^\",\n    \"mathjax\": \"^3.2.0\",\n    \"react\": \">=18.1.0\"\n  },\n  \"peerDependenciesMeta\": {\n    \"liqvid\": {\n      \"optional\": true\n    },\n    \"mathjax\": {\n      \"optional\": true\n    }\n  },\n  \"devDependencies\": {\n    \"liqvid\": \"workspace:^\",\n    \"mathjax\": \"^3.2.0\"\n  },\n  \"dependencies\": {\n    \"@liqvid/utils\": \"workspace:^\"\n  }\n}\n"
  },
  {
    "path": "packages/mathjax/src/RenderGroup.ts",
    "content": "import {recursiveMap, usePromise} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport {\n  cloneElement,\n  forwardRef,\n  isValidElement,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n} from \"react\";\nimport {MJX as MJXFancy} from \"./fancy\";\nimport {Handle as MJXHandle, MJX as MJXPlain} from \"./plain\";\n\ninterface Handle {\n  /** Promise that resolves once all descendant <MJX>s have rendered */\n  ready: Promise<void>;\n}\n\ninterface Props {\n  children?: React.ReactNode;\n\n  /**\n   * Whether to reparse descendants for `during()` and `from()`\n   * @default false\n   */\n  reparse?: boolean;\n}\n\n/**\n * Wait for a bunch of things to be rendered\n */\n// @ts-expect-error we don't know how to type `recursiveMap` yet\nexport const RenderGroup = forwardRef<Handle, Props>(\n  function RenderGroup(props, ref) {\n    const [ready, resolve] = usePromise();\n\n    // handle\n    useImperativeHandle(ref, () => ({ready}));\n\n    const elements = useRef<HTMLSpanElement[]>([]);\n    const promises = useRef<Promise<unknown>[]>([]);\n\n    // reparsing\n    const player = usePlayer();\n    useEffect(() => {\n      // promises\n      Promise.all(promises.current).then(() => {\n        // reparse\n        if (props.reparse) {\n          player.reparseTree(leastCommonAncestor(elements.current));\n        }\n\n        // ready()\n        resolve();\n      });\n    }, []);\n\n    return recursiveMap(props.children, (node) => {\n      if (shouldInspect(node)) {\n        const originalRef = node.ref;\n        return cloneElement(node, {\n          ref: (ref: MJXHandle) => {\n            if (!ref) return;\n\n            elements.current.push(ref.domElement);\n            promises.current.push(ref.ready);\n\n            // pass along original ref\n            if (typeof originalRef === \"function\") {\n              originalRef(ref);\n            } else if (originalRef && typeof originalRef === \"object\") {\n              (originalRef as React.MutableRefObject<MJXHandle>).current = ref;\n            }\n          },\n        });\n      }\n\n      return node;\n    });\n  },\n);\n\n/** Whether the element is an <MJX> */\nfunction shouldInspect(\n  node: React.ReactNode,\n): node is React.ReactElement & React.RefAttributes<MJXHandle> {\n  return (\n    isValidElement(node) &&\n    typeof node.type === \"object\" &&\n    (node.type === MJXFancy || node.type === MJXPlain)\n  );\n}\n\n/**\n * Find least common ancestor of an array of elements\n * @param elements Elements\n * @returns Deepest node containing all passed elements\n */\nfunction leastCommonAncestor(elements: HTMLElement[]): HTMLElement {\n  if (elements.length === 0) {\n    throw new Error(\"Must pass at least one element\");\n  }\n\n  let ancestor = elements[0];\n  let failing = elements.slice(1);\n  while (failing.length > 0) {\n    ancestor = ancestor.parentElement;\n    failing = failing.filter((node) => !ancestor.contains(node));\n  }\n  return ancestor;\n}\n"
  },
  {
    "path": "packages/mathjax/src/fancy.tsx",
    "content": "import {combineRefs} from \"@liqvid/utils/react\";\nimport {usePlayer} from \"liqvid\";\nimport {forwardRef, useEffect, useRef} from \"react\";\nimport {Handle, MJX as MJXPlain, MJXText as MJXTextPlain} from \"./plain\";\n\ninterface Props extends React.ComponentProps<typeof MJXPlain> {\n  /**\n   * Player events to obstruct.\n   * @default \"canplay canplaythrough\"\n   */\n  obstruct?: string;\n\n  /**\n   * Whether to reparse the canvas.\n   * @default false\n   */\n  reparse?: boolean;\n}\n\n/** Component for MathJax code */\nexport const MJX = forwardRef<Handle, Props>(function MJX(props, ref) {\n  const {\n    obstruct = \"canplay canplaythrough\",\n    reparse = false,\n    ...attrs\n  } = props;\n\n  const plain = useRef<Handle>();\n  const combined = combineRefs(plain, ref);\n\n  const player = usePlayer();\n\n  useEffect(() => {\n    // obstruction\n    const obstructions = obstruct.split(\" \");\n    if (obstructions.includes(\"canplay\")) {\n      player.obstruct(\"canplay\", plain.current.ready);\n    }\n    if (obstructions.includes(\"canplaythrough\")) {\n      player.obstruct(\"canplaythrough\", plain.current.ready);\n    }\n\n    // reparsing\n    if (reparse) {\n      plain.current.ready.then(() =>\n        player.reparseTree(plain.current.domElement),\n      );\n    }\n  }, []);\n\n  return <MJXPlain ref={combined} {...attrs} />;\n});\n\nexport const MJXText = forwardRef<\n  {},\n  React.ComponentProps<typeof MJXTextPlain>\n>(function MJXText(props, ref) {\n  const {...attrs} = props;\n  return <MJXTextPlain tagName=\"p\" {...attrs} />;\n});\n"
  },
  {
    "path": "packages/mathjax/src/index.ts",
    "content": "declare global {\n  // alas: https://github.com/mathjax/MathJax/issues/2197#issuecomment-531566828\n  const MathJax: any;\n}\n\nexport {MathJaxReady} from \"./loading\";\nexport {Handle} from \"./plain\";\nexport * from \"./fancy\";\nexport {RenderGroup} from \"./RenderGroup\";\n"
  },
  {
    "path": "packages/mathjax/src/loading.ts",
    "content": "/**\n * Ready Promise\n */\nconst packages = MathJax._.components.package.Package.packages;\n// output/svg doesn't load reliably for some reason...\nconst packageNames = Array.from(packages.keys()).filter(\n  (name) => name !== \"output/svg\",\n);\n\nexport const MathJaxReady = Promise.all([\n  MathJax.loader.ready(...packageNames),\n  MathJax.startup.promise,\n]);\n"
  },
  {
    "path": "packages/mathjax/src/plain.tsx",
    "content": "import {combineRefs, usePromise} from \"@liqvid/utils/react\";\nimport {\n  createElement,\n  forwardRef,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n} from \"react\";\nimport {MathJaxReady} from \"./loading\";\n\n/**\n * MJX element API\n */\nexport interface Handle {\n  /** Underlying <span> or <mjx-container> element. */\n  domElement: HTMLElement;\n\n  /** Promise that resolves once typesetting is finished */\n  ready: Promise<void>;\n}\n\ninterface Props extends React.HTMLAttributes<HTMLSpanElement> {\n  /**\n   * Whether to render in display mode\n   * @default false\n   */\n  display?: boolean;\n\n  /**\n   * Whether to rerender on resize (necessary for XyJax)\n   * @default false\n   */\n  resize?: boolean;\n\n  /**\n   * Whether to wrap in a <span> element or insert directly (default)\n   * @default false\n   */\n  span?: boolean;\n}\n\n/** Component for MathJax code */\nexport const MJX = forwardRef<Handle, Props>(function MJX(props, ref) {\n  const {\n    children,\n    display = false,\n    resize = false,\n    span = false,\n    ...attrs\n  } = props;\n\n  const spanRef = useRef<HTMLElement>();\n  const [ready, resolve] = usePromise();\n\n  /* typeset */\n  useEffect(() => {\n    MathJaxReady.then(() => {\n      MathJax.typeset([spanRef.current]);\n\n      // replace wrapper span with content\n      if (!span) {\n        const element = spanRef.current.firstElementChild as HTMLElement;\n\n        // copy id\n        element.id = spanRef.current.id;\n\n        // copy classes\n        for (let i = 0, len = spanRef.current.classList.length; i < len; ++i) {\n          element.classList.add(spanRef.current.classList.item(i));\n        }\n\n        // copy dataset\n        Object.assign(element.dataset, spanRef.current.dataset);\n\n        // overwrite element\n        spanRef.current.replaceWith(element);\n        spanRef.current = element;\n      }\n\n      resolve();\n    });\n  }, [String(children)]);\n\n  // handle\n  useImperativeHandle(ref, () => ({\n    get domElement() {\n      return spanRef.current;\n    },\n    ready,\n  }));\n\n  const [open, close] = display ? [\"\\\\[\", \"\\\\]\"] : [\"\\\\(\", \"\\\\)\"];\n\n  // Google Chrome fails without this\n  // if (display) {\n  //   if (!attrs.style)\n  //     attrs.style = {};\n  //   attrs.style.display = \"block\";\n  // }\n\n  return (\n    <span {...attrs} ref={spanRef}>\n      {open + children + close}\n    </span>\n  );\n});\n\n// function onFullScreenChange(callback: EventListener): void {\n//   for (const event of [\"fullscreenchange\", \"webkitfullscreenchange\", \"mozfullscreenchange\", \"MSFullscreenChange\"])\n//     document.addEventListener(event, callback);\n// }\n\n//   constructor(props: Props) {\n//     super(props);\n//     this.hub = new EventEmitter();\n//     // hub will have lots of listeners, turn off warning\n//     this.hub.setMaxListeners(0);\n\n//     this.ready = new Promise((resolve) => this.resolveReady = resolve);\n\n//     for (const method of [\"Rerender\", \"Text\", \"Typeset\"]) {\n//       this[method] = this[method].bind(this);\n//     }\n//   }\n\n//   async componentDidMount() {\n//     await MathJaxReady;\n\n//     this.Typeset()\n//     .then(() => this.jax = MathJax.Hub.getAllJax(this.domElement)[0])\n//     .then(this.resolveReady);\n\n//     if (this.props.resize) {\n//       window.addEventListener(\"resize\", this.Rerender);\n//       onFullScreenChange(this.Rerender);\n//     }\n//   }\n\n//   shouldComponentUpdate(nextProps: Props) {\n//     const text = this.props.children instanceof Array ? this.props.children.join(\"\") : this.props.children,\n//           nextText = nextProps.children instanceof Array ? nextProps.children.join(\"\") : nextProps.children;\n\n//     // rerender?\n//     if (this.jax && text !== nextText) {\n//       this.Text(nextProps.children as string);\n//     }\n\n//     // classes changed?\n//     if (this.props.className !== nextProps.className) {\n//       const classes = this.props.className ? this.props.className.split(\" \") : [],\n//             newClasses = nextProps.className ? nextProps.className.split(\" \") : [];\n\n//       const add = newClasses.filter(_ => !classes.includes(_)),\n//             remove = classes.filter(_ => !newClasses.includes(_));\n\n//       for (const _ of remove)\n//         this.domElement.classList.remove(_);\n//       for (const _ of add)\n//         this.domElement.classList.add(_);\n//     }\n\n//     // style attribute changed?\n//     if (JSON.stringify(this.props.style) !== JSON.stringify(nextProps.style)) {\n//       (Object.keys(this.props.style || {}) as (keyof React.CSSProperties)[])\n//       .filter(_ => !(nextProps.style || {}).hasOwnProperty(_))\n//       .forEach(_ => this.props.style[_] = null);\n//       Object.assign(this.domElement.style, nextProps.style);\n//     }\n\n//     return false;\n//   }\n\n//   Rerender() {\n//     MathJax.Hub.Queue([\"Rerender\", MathJax.Hub, this.domElement]);\n//     MathJax.Hub.Queue(() => this.hub.emit(\"Rerender\"));\n//   }\n\n//   Text(text: string): Promise<void> {\n//     return new Promise((resolve) => {\n//       const tasks: [] = [];\n\n//       if (this.props.renderer) {\n//         const renderer = MathJax.Hub.config.menuSettings.renderer;\n//         tasks.push([\"setRenderer\", MathJax.Hub, this.props.renderer]);\n//         tasks.push([\"Text\", this.jax, text]);\n//         tasks.push([\"setRenderer\", MathJax.Hub, renderer]);\n//       } else {\n//         tasks.push([\"Text\", this.jax, text]);\n//       }\n\n//       tasks.push(() => this.hub.emit(\"Text\"));\n//       tasks.push(resolve);\n\n//       MathJax.Hub.Queue(...tasks);\n//     });\n//   }\n\n//   Typeset(): Promise<void> {\n//     return new Promise((resolve) => {\n//       const tasks = [];\n\n//       if (this.props.renderer) {\n//         const renderer = MathJax.Hub.config.menuSettings.renderer;\n//         tasks.push([\"setRenderer\", MathJax.Hub, this.props.renderer]);\n//         tasks.push([\"Typeset\", MathJax.Hub, this.domElement]);\n//         tasks.push([\"setRenderer\", MathJax.Hub, renderer]);\n//       } else {\n//         tasks.push([\"Typeset\", MathJax.Hub, this.domElement]);\n//       }\n\n//       tasks.push(() => this.hub.emit(\"Typeset\"));\n//       tasks.push(resolve);\n\n//       MathJax.Hub.Queue(...tasks);\n//     });\n//   }\n// }\n\n/**\n * Element which will render any MathJax contained inside\n */\nexport const MJXText = forwardRef<\n  unknown,\n  {\n    /** HTML tag to insert.\n     * @default \"p\"\n     */\n    tagName?: keyof (HTMLElementTagNameMap & JSX.IntrinsicElements);\n  } & React.HTMLAttributes<HTMLElement>\n>(function MJXText(props, ref) {\n  const elt = useRef<HTMLElement>();\n  const combined = combineRefs(elt, ref);\n\n  useEffect(() => {\n    MathJax.startup.promise.then(() => {\n      MathJax.typeset([elt.current]);\n    });\n  }, []);\n\n  const {tagName = \"p\", children, ...attrs} = props;\n  return createElement(tagName, {...attrs, ref: combined}, children);\n});\n"
  },
  {
    "path": "packages/mathjax/test/index.js",
    "content": "import {jsx} from \"react/jsx-runtime\";\nimport {Utils, usePlayer} from \"liqvid\";\nimport {\n  forwardRef,\n  useRef,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n} from \"react\";\n\nconst implementation = function MJX(props, ref) {\n  const {\n    children,\n    async = false,\n    display = false,\n    resize = false,\n    span = false,\n    ...attrs\n  } = props;\n  const spanRef = useRef();\n  const [ready, resolve] = usePromise();\n  useEffect(() => {\n    MathJax.startup.promise.then(() => {\n      MathJax.typeset([spanRef.current]);\n      resolve();\n    });\n  }, []);\n  useImperativeHandle(ref, () => ({\n    get domElement() {\n      return spanRef.current;\n    },\n    ready,\n  }));\n  const [open, close] = display ? [\"\\\\[\", \"\\\\]\"] : [\"\\\\(\", \"\\\\)\"];\n  return jsx(\"span\", {\n    ...attrs,\n    ref: spanRef,\n    children: open + children + close,\n  });\n};\nconst MJX$1 = forwardRef(implementation);\nfunction usePromise(deps = []) {\n  const resolveRef = useRef();\n  const promise = useMemo(\n    () =>\n      new Promise((resolve) => {\n        resolveRef.current = resolve;\n      }),\n    [],\n  );\n  return [promise, resolveRef.current];\n}\n\nconst {combineRefs} = Utils.react;\nconst MJX = forwardRef(function MJX(props, ref) {\n  const {reparse = false, ...attrs} = props;\n  const plain = useRef();\n  const combined = combineRefs(plain, ref);\n  const player = usePlayer();\n  useEffect(() => {\n    if (reparse) {\n      plain.current.ready.then(() =>\n        player.reparseTree(plain.current.domElement),\n      );\n    }\n  }, []);\n  return jsx(MJX$1, {ref: combined, ...attrs});\n});\n\nexport {MJX};\n"
  },
  {
    "path": "packages/mathjax/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"module\": \"esnext\",\n    \"outDir\": \"./dist/esm\",\n    \"rootDir\": \"./src\",\n    \"target\": \"esnext\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/playback/CHANGELOG.md",
    "content": "## 1.2.0 (December 7, 2025)\n\n- setting volume programmatically should not unmute\n- add `usePlaybackEvent()`\n\n## 1.1.7 (February 1, 2025)\n\n- add types to `exports` field\n- fix server-side rendering\n\n## 1.1.6 (November 13, 2022)\n\n- fire `stop` event when playback is seeked to its end\n"
  },
  {
    "path": "packages/playback/README.md",
    "content": "# @liqvid/playback\n\nThis 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.\n"
  },
  {
    "path": "packages/playback/jest.config.js",
    "content": "module.exports = {\n  coverageReporters: [\"json-summary\"],\n  preset: \"ts-jest\",\n  testEnvironment: \"jsdom\",\n  testPathIgnorePatterns: [\"dist\"],\n};\n"
  },
  {
    "path": "packages/playback/package.json",
    "content": "{\n  \"name\": \"@liqvid/playback\",\n  \"version\": \"1.2.0\",\n  \"description\": \"Playback class for Liqvid\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    },\n    \"./react\": {\n      \"import\": \"./dist/esm/react.mjs\",\n      \"require\": \"./dist/cjs/react.cjs\",\n      \"types\": \"./dist/types/react.d.ts\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\n        \"./dist/types/*\"\n      ]\n    }\n  },\n  \"files\": [\n    \"dist/*\"\n  ],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:js && pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc --module esnext --outDir dist/esm; tsc --module commonjs --moduleResolution node --outDir dist/cjs; node ../../build.mjs\",\n    \"build:postclean\": \"find . -name tsconfig.tsbuildinfo -delete\",\n    \"lint\": \"pnpm biome check --fix\",\n    \"test\": \"jest\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/playback#readme\",\n  \"devDependencies\": {\n    \"babel-jest\": \"^27.4.6\"\n  },\n  \"dependencies\": {\n    \"@liqvid/utils\": \"workspace:^\",\n    \"@lqv/playback\": \"catalog:\",\n    \"@types/events\": \"^3.0.0\",\n    \"events\": \"^3.3.0\",\n    \"strict-event-emitter-types\": \"^2.0.0\"\n  },\n  \"sideEffects\": false,\n  \"peerDependencies\": {\n    \"react\": \"catalog:peer\"\n  },\n  \"peerDependenciesMeta\": {\n    \"react\": {\n      \"optional\": true\n    }\n  }\n}\n"
  },
  {
    "path": "packages/playback/src/animation.ts",
    "content": "import { isClient } from \"@liqvid/utils/ssr\";\n\nimport { Playback as CorePlayback } from \"./core\";\n\ndeclare global {\n  interface Animation {\n    /**\n     * Explicitly persists an animation, when it would otherwise be removed due to the browser's\n     * [Automatically removing filling animations](https://developer.mozilla.org/en-US/docs/Web/API/Animation#automatically_removing_filling_animations) behavior.\n     */\n    persist(): void;\n  }\n}\n\n/** Extended {@link CorePlayback Playback} supporting the Web Animation API */\nexport class Playback extends CorePlayback {\n  private __animations: Animation[] = [];\n  private __delays = new WeakMap<AnimationEffect, number>();\n\n  /** {@link DocumentTimeline} synced up to this playback */\n  timeline: DocumentTimeline;\n\n  constructor(options: ConstructorParameters<typeof CorePlayback>[0]) {\n    super(options);\n\n    if (isClient) {\n      this.__createTimeline();\n    }\n  }\n\n  /**\n   * Create an {@link Animation} (factory) synced to this playback\n   * @param keyframes A [keyframes object](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Keyframe_Formats) or `null`\n   * @param options Either an integer representing the animation's duration (in milliseconds), or {@link KeyframeEffectOptions}\n   * @returns A callback to attach the animation to a target\n   */\n  newAnimation<T extends Element>(\n    keyframes: Keyframe[] | PropertyIndexedKeyframes,\n    options?: number | KeyframeEffectOptions,\n  ): (target: T) => Animation {\n    let anim: Animation;\n\n    return (target: T) => {\n      if (target === null) {\n        anim.cancel();\n        anim = undefined;\n        return;\n      } else if (anim !== undefined) {\n        console.warn(\n          \"Animations should not be reused as they will not cancel properly. Check animations attached to \",\n          target,\n        );\n      }\n\n      // create animation\n      anim = new Animation(\n        new KeyframeEffect(target, keyframes, options),\n        this.timeline,\n      );\n      if (\n        typeof options === \"object\" &&\n        (options.fill === \"forwards\" || options.fill === \"both\")\n      ) {\n        anim.persist();\n      }\n      /* adopt animation */\n      const delay = anim.effect.getTiming().delay;\n      this.__delays.set(anim.effect, delay);\n\n      anim.currentTime = (this.currentTime - delay) / this.playbackRate;\n      anim.startTime = null;\n      anim.pause();\n\n      if (delay !== 0) {\n        anim.effect.updateTiming({ delay: 0.1 });\n      }\n\n      this.__animations.push(anim);\n      anim.addEventListener(\"cancel\", () => {\n        this.__animations.splice(this.__animations.indexOf(anim), 1);\n      });\n\n      // return\n      return anim;\n    };\n  }\n\n  /**\n   * Create our timeline\n   *\n   * @listens pause\n   * @listens play\n   * @listens ratechange\n   * @listens seek\n   */\n  private __createTimeline(): void {\n    // don't crash old browsers when not polyfilled\n    if (typeof window.DocumentTimeline === \"undefined\") {\n      return;\n    }\n    this.timeline = new DocumentTimeline();\n\n    // pause\n    this.on(\"pause\", () => {\n      for (const anim of this.__animations) {\n        anim.pause();\n      }\n    });\n\n    // play\n    this.on(\"play\", () => {\n      for (const anim of this.__animations) {\n        anim.startTime = null;\n        anim.play();\n        anim.startTime =\n          (this.timeline.currentTime as number) +\n          (this.__delays.get(anim.effect) - this.currentTime) /\n            this.playbackRate;\n      }\n    });\n\n    // ratechange\n    this.on(\"ratechange\", () => {\n      for (const anim of this.__animations) {\n        anim.playbackRate = this.playbackRate;\n      }\n    });\n\n    // seek\n    this.on(\"seek\", () => {\n      for (const anim of this.__animations) {\n        const offset =\n          (this.__delays.get(anim.effect) - this.currentTime) /\n          this.playbackRate;\n        if (this.paused) {\n          // anim.startTime = this.timeline.currentTime + offset\n          anim.currentTime = -offset;\n          anim.pause();\n        } else {\n          anim.startTime = (this.timeline.currentTime as number) + offset;\n        }\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "packages/playback/src/core.ts",
    "content": "/** biome-ignore-all lint/suspicious/noConfusingVoidType: event emitter types */\nimport { EventEmitter } from \"events\";\n\nimport { bind, constrain } from \"@liqvid/utils/misc\";\nimport { isClient } from \"@liqvid/utils/ssr\";\nimport type StrictEventEmitter from \"strict-event-emitter-types\";\n\nexport interface PlaybackEventMap {\n  bufferupdate: void;\n  cuechange: void;\n  durationchange: void;\n  pause: void;\n  play: void;\n  seek: number;\n  seeked: void;\n  seeking: void;\n  stop: void;\n  ratechange: void;\n  timeupdate: number;\n  volumechange: void;\n}\n\nexport type PlaybackEvent =\n  | \"bufferupdate\"\n  | \"cuechange\"\n  | \"durationchange\"\n  | \"pause\"\n  | \"play\"\n  | \"ratechange\"\n  | \"seek\"\n  | \"seeked\"\n  | \"seeking\"\n  | \"stop\"\n  | \"timeupdate\"\n  | \"volumechange\";\n\ndeclare let webkitAudioContext: typeof AudioContext;\n\n/**\n * Class pretending to be a media element advancing in time.\n *\n * Imitates {@link HTMLMediaElement} to a certain extent, although it does not implement that interface.\n */\nexport class Playback extends (EventEmitter as unknown as new () => StrictEventEmitter<\n  EventEmitter,\n  PlaybackEventMap\n>) {\n  /** Audio context owned by this playback */\n  audioContext: AudioContext;\n\n  /** Audio node owned by this playback */\n  audioNode: GainNode;\n\n  /**\n    The current playback time in milliseconds.\n    \n    **Warning:** {@link HTMLMediaElement.currentTime} measures this property in *seconds*.\n  */\n  currentTime = 0;\n\n  /** Flag indicating whether playback is currently paused. */\n  paused = true;\n\n  /* private fields */\n  private __playingFrom: number;\n  private __startTime: number;\n\n  /* private fields exposed by getters */\n  private __captions: DocumentFragment[] = [];\n  private __duration: number;\n  private __playbackRate = 1;\n  private __muted = false;\n  private __seeking = false;\n  private __volume = 1;\n\n  constructor(options: {\n    /** Duration of the playback in milliseconds */\n    duration: number;\n  }) {\n    super();\n\n    this.duration = options.duration;\n    this.__playingFrom = 0;\n    this.__startTime = performance.now();\n\n    // we will have lots of listeners, turn off warning\n    this.setMaxListeners(0);\n\n    // bind methods\n    bind(this, [\"pause\", \"play\"]);\n    this.__advance = this.__advance.bind(this);\n\n    // browser-only\n    if (isClient) {\n      // audio\n      this.__initAudio();\n\n      // initiate playback loop\n      requestAnimationFrame(this.__advance);\n    }\n  }\n\n  /* magic properties */\n\n  /** Gets or sets the current captions */\n  get captions(): DocumentFragment[] {\n    return this.__captions;\n  }\n\n  /** @emits cuechange */\n  set captions(captions: DocumentFragment[]) {\n    this.__captions = captions;\n\n    this.emit(\"cuechange\");\n  }\n\n  /**\n   * Length of the playback in milliseconds.\n   *\n   * **Warning:** {@link HTMLMediaElement.duration} measures this in *seconds*.\n   */\n  get duration(): number {\n    return this.__duration;\n  }\n\n  /** @emits durationchange */\n  set duration(duration: number) {\n    if (duration === this.__duration) return;\n\n    this.__duration = duration;\n\n    this.emit(\"durationchange\");\n  }\n\n  /** Gets or sets a flag that indicates whether playback is muted. */\n  get muted(): boolean {\n    return this.__muted;\n  }\n\n  /** @emits volumechange */\n  set muted(val: boolean) {\n    if (val === this.__muted) return;\n\n    this.__muted = val;\n\n    if (this.audioNode) {\n      if (this.__muted) {\n        this.audioNode.gain.value = 0;\n      } else {\n        this.audioNode.gain.setValueAtTime(\n          this.volume,\n          this.audioContext.currentTime,\n        );\n      }\n    }\n\n    this.emit(\"volumechange\");\n  }\n\n  /** Gets or sets the current rate of speed for the playback. */\n  get playbackRate(): number {\n    return this.__playbackRate;\n  }\n\n  /** @emits ratechange */\n  set playbackRate(val: number) {\n    if (val === this.__playbackRate) return;\n\n    this.__playbackRate = val;\n    this.__playingFrom = this.currentTime;\n    this.__startTime = performance.now();\n    this.emit(\"ratechange\");\n  }\n\n  /** Gets or sets a flag that indicates whether the playback is currently moving to a new position. */\n  get seeking(): boolean {\n    return this.__seeking;\n  }\n\n  /**\n   * @emits seeking\n   * @emits seeked\n   */\n  set seeking(val: boolean) {\n    if (val === this.__seeking) return;\n\n    this.__seeking = val;\n    if (this.__seeking) this.emit(\"seeking\");\n    else this.emit(\"seeked\");\n  }\n\n  /**\n   * Pause playback.\n   *\n   * @emits pause\n   */\n  pause(): void {\n    this.paused = true;\n    this.__playingFrom = this.currentTime;\n\n    this.emit(\"pause\");\n  }\n\n  /**\n   * Start or resume playback.\n   *\n   * @emits play\n   */\n  play(): void {\n    this.paused = false;\n\n    // this is necessary for currentTime to be correct when playing from stop state\n    this.currentTime = this.__playingFrom;\n    this.__startTime = performance.now();\n\n    this.emit(\"play\");\n  }\n\n  /**\n   * Seek playback to a specific time.\n   *\n   * @emits seek\n   */\n  seek(t: number): void {\n    t = constrain(0, t, this.duration);\n\n    this.currentTime = this.__playingFrom = t;\n    this.__startTime = performance.now();\n\n    this.emit(\"seek\", t);\n\n    if (this.currentTime >= this.duration) {\n      this.stop();\n    }\n  }\n\n  /** Gets or sets the volume level for the playback. */\n  get volume(): number {\n    return this.__volume;\n  }\n\n  /** @emits volumechange */\n  set volume(volume: number) {\n    const prevVolume = this.__volume;\n    this.__volume = constrain(0, volume, 1);\n\n    if (this.audioNode) {\n      if (prevVolume === 0 || this.__volume === 0) {\n        this.audioNode.gain.setValueAtTime(0, this.audioContext.currentTime);\n      } else {\n        this.audioNode.gain.exponentialRampToValueAtTime(\n          this.__volume,\n          this.audioContext.currentTime + 2,\n        );\n      }\n    }\n\n    this.emit(\"volumechange\");\n  }\n\n  /**\n   * Stop playback and reset pointer to start\n   *\n   * @emits stop\n   */\n  stop(): void {\n    this.paused = true;\n    this.__playingFrom = 0;\n\n    this.emit(\"stop\");\n  }\n\n  /* private methods */\n\n  /**\n   * @emits timeupdate\n   */\n  private __advance(t: number): void {\n    // paused\n    if (this.paused || this.__seeking) {\n      this.__startTime = t;\n    } else {\n      // playing\n      this.currentTime =\n        this.__playingFrom +\n        Math.max((t - this.__startTime) * this.__playbackRate, 0);\n\n      if (this.currentTime >= this.duration) {\n        this.currentTime = this.duration;\n        this.stop();\n      }\n\n      this.emit(\"timeupdate\", this.currentTime);\n    }\n\n    requestAnimationFrame(this.__advance);\n  }\n\n  /**\n   * Try to initiate audio\n   *\n   * @listens click\n   * @listens keydown\n   * @listens touchstart\n   */\n  private __initAudio(): void {\n    const requestAudioContext = (): void => {\n      try {\n        this.audioContext = new (window.AudioContext || webkitAudioContext)();\n        this.audioNode = this.audioContext.createGain();\n        this.audioNode.connect(this.audioContext.destination);\n\n        window.removeEventListener(\"click\", requestAudioContext);\n        window.removeEventListener(\"keydown\", requestAudioContext);\n        window.removeEventListener(\"touchstart\", requestAudioContext);\n      } catch (_e) {\n        // console.log(\"Failed to create audio context\");\n      }\n    };\n    window.addEventListener(\"click\", requestAudioContext);\n    window.addEventListener(\"keydown\", requestAudioContext);\n    window.addEventListener(\"touchstart\", requestAudioContext);\n  }\n}\n"
  },
  {
    "path": "packages/playback/src/index.ts",
    "content": "export { Playback } from \"./animation\";\nexport { Playback as CorePlayback } from \"./core\";\n"
  },
  {
    "path": "packages/playback/src/react.ts",
    "content": "import { createContext, useContext, useEffect, useRef } from \"react\";\n\nimport type { PlaybackEvent } from \"./core\";\n\nimport type { Playback } from \".\";\n\ntype GlobalThis = {\n  [symbol]: React.Context<Playback>;\n};\n\nconst symbol = Symbol.for(\"@lqv/playback\");\n\nif (!(symbol in globalThis)) {\n  (globalThis as unknown as GlobalThis)[symbol] = createContext<Playback>(null);\n}\n\n/**\n * {@link React.Context} used to access ambient {@link Playback}\n */\nexport const PlaybackContext = (globalThis as unknown as GlobalThis)[symbol];\n\n/** Access the ambient {@link Playback} */\nexport function usePlayback(): Playback {\n  return useContext(PlaybackContext);\n}\n\n/** Register a callback for time update. */\nexport function useTime(\n  callback: (value: number) => void,\n  deps?: React.DependencyList,\n): void;\nexport function useTime<T = number>(\n  callback: (value: T) => void,\n  transform?: (t: number) => T,\n  deps?: React.DependencyList,\n): void;\nexport function useTime<T = number>(\n  callback: (value: T) => void,\n  transform?: ((t: number) => T) | React.DependencyList,\n  deps?: React.DependencyList,\n): void {\n  const playback = usePlayback();\n  const prev = useRef<T>();\n\n  useEffect(\n    () => {\n      const listener =\n        typeof transform === \"function\"\n          ? (t: number) => {\n              const value = transform(t);\n              if (value !== prev.current) callback(value);\n              prev.current = value;\n            }\n          : (t: number & T) => {\n              if (t !== prev.current) callback(t);\n              prev.current = t;\n            };\n\n      // subscriptions\n      playback.on(\"seek\", listener);\n      playback.on(\"timeupdate\", listener);\n\n      // initial call\n      listener(playback.currentTime);\n\n      // unsubscriptions\n      return () => {\n        playback.off(\"seek\", listener);\n        playback.off(\"timeupdate\", listener);\n      };\n    },\n    // biome-ignore lint/correctness/useExhaustiveDependencies: this is magic that will be removed in next version\n    typeof transform === \"function\" ? deps : transform,\n  );\n}\n\n/** Subscribe to playback events. */\nexport function usePlaybackEvent(\n  /** Event to subscribe to */\n  eventName: PlaybackEvent,\n\n  /** Event callback to register */\n  callback: () => unknown,\n\n  /** Playback to subscribe to. Defaults to loading from context. */\n  playback?: Playback,\n) {\n  const contextPlayback = usePlayback();\n  playback ??= contextPlayback;\n\n  useEffect(() => {\n    playback.on(eventName, callback);\n\n    return () => {\n      playback.off(eventName, callback);\n    };\n  }, [callback, eventName, playback]);\n}\n"
  },
  {
    "path": "packages/playback/tests/core.test.ts",
    "content": "import { Playback } from \"../src/index\";\n\nit(\"should stop() when seeked to end\", () => {\n  const playback = new Playback({ duration: 60000 });\n  const onStop = jest.fn();\n  playback.on(\"stop\", onStop);\n  playback.seek(playback.duration);\n  expect(onStop).toHaveBeenCalledTimes(1);\n});\n"
  },
  {
    "path": "packages/playback/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"extends\": \"../../tsconfig.json\",\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/player/package.json",
    "content": "{\n  \"name\": \"@liqvid/player\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Player gui for Liqvid\",\n  \"main\": \"dist/index.js\",\n  \"typings\": \"dist/index.d.ts\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid#readme\",\n  \"dependencies\": {\n    \"@liqvid/keymap\": \"^0.0.1\",\n    \"@liqvid/playback\": \"^0.0.1\",\n    \"@liqvid/utils\": \"^0.0.1\"\n  },\n  \"sideEffects\": false\n}\n"
  },
  {
    "path": "packages/polyfills/package.json",
    "content": "{\n  \"name\": \"@liqvid/polyfills\",\n  \"version\": \"0.0.1\",\n  \"description\": \"Polyfills used by Liqvid\",\n  \"files\": [\"dist/*\"],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid#readme\",\n  \"sideEffects\": true,\n  \"dependencies\": {\n    \"pepjs\": \"^0.5.3\"\n  }\n}\n"
  },
  {
    "path": "packages/polyfills/src/polyfills.ts",
    "content": ""
  },
  {
    "path": "packages/polyfills/src/waapi.js",
    "content": "(async () => {\n  const POLYFILL_URL =\n    \"https://cdnjs.cloudflare.com/ajax/libs/web-animations/2.3.2/web-animations-next.min.js\";\n\n  /** Polyfill the Web Animations API */\n  if (typeof DocumentTimeline !== \"undefined\") return;\n\n  document.write(`<script src=\"${POLYFILL_URL}\"></script>`);\n\n  const script = document.querySelector(`script[src=\"${POLYFILL_URL}\"]`);\n\n  await new Promise((resolve, reject) => {\n    script.addEventListener(\"load\", resolve);\n  });\n\n  // DocumentTimeline\n  window.DocumentTimeline = function () {\n    const self = Object.assign(\n      Object.create(Object.getPrototypeOf(document.timeline)),\n      document.timeline,\n    );\n    self.currentTime = 0;\n    return self;\n  };\n  // Animation.persist()\n  Animation.prototype.persist = () => {};\n\n  // add to document.timeline\n  const pause = Animation.prototype.pause;\n  Animation.prototype.pause = function () {\n    pause.call(this);\n    if (!document.timeline._animations.includes(this))\n      document.timeline._animations.push(this);\n  };\n\n  // getAnimations()\n  document.getAnimations = () => document.timeline._animations;\n\n  // KeyframeEffect.getTiming()\n  const timingProps = [\n    \"delay\",\n    \"direction\",\n    \"duration\",\n    \"easing\",\n    \"endDelay\",\n    \"fill\",\n    \"iterationStart\",\n    \"iterations\",\n  ];\n  KeyframeEffect.prototype.getTiming = function () {\n    const proxy = {};\n    for (const prop of timingProps) {\n      Object.defineProperty(proxy, prop, {\n        get: () => {\n          return this._timing[\"_\" + prop];\n        },\n      });\n    }\n    return proxy;\n  };\n\n  // KeyframeEffect.updateTiming()\n  KeyframeEffect.prototype.updateTiming = function (o) {\n    for (const prop of timingProps) {\n      if (o.hasOwnProperty(prop)) {\n        this._timing[\"_\" + prop] = o[prop];\n      }\n    }\n  };\n\n  // Animation.startTime\n  Object.defineProperty(Animation.prototype, \"startTime\", {\n    set: function (v) {\n      this.currentTime = -v;\n    },\n  });\n})();\n"
  },
  {
    "path": "packages/prompt/README.md",
    "content": "# @liqvid/prompt\n\nThis is a [Liqvid](https://liqvidjs.org) plugin providing prompts to read from when recording.\n\n## Usage\n```tsx\n/* markers */\nconst markers = [\n  [\"intro/\", \"1:00\"],\n  [\"intro/second\", \"1:00\"],\n  [\"intro/inline\", \"1:00\"]\n];\n\n/* usage */\nimport {Prompt, Cue} from \"@liqvid/prompt\";\n\n// any attributes that are valid on <div> will be passed down\ntype P = Parameters<typeof Prompt>[0];\n\nexport const IntroPrompt = (props: P) => (\n  <Prompt {...props}>\n    <Cue on=\"intro/\">\n      This is the first thing you want to say. You can use\n      <br/>\n      br tags inside cues.\n    </Cue>\n    <Cue on=\"intro/second\">\n      This is the second thing you want to say. You can also use empty cue tags <Cue on=\"intro/inline\"/> for small transitions without interrupting your reading flow.\n    </Cue>\n  </Prompt>\n);\n```\n"
  },
  {
    "path": "packages/prompt/package.json",
    "content": "{\n  \"name\": \"@liqvid/prompt\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Liqvid plugin providing prompts to read from\",\n  \"exports\": {\n    \".\": \"./dist/index.js\"\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/*\"]\n    }\n  },\n  \"files\": [\"dist/*\"],\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:js && pnpm build:css && pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:css\": \"stylus -o dist/style.css style.styl; stylus -c -o dist/style.min.css style.styl\",\n    \"build:js\": \"tsc --module esnext --outDir dist/esm; tsc --module commonjs --outDir dist/cjs; node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"css\": \"stylus -c -w -o dist/style.css style.styl\",\n    \"lint\": \"eslint --ext ts,tsx --fix src\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/prompt\",\n  \"license\": \"MIT\",\n  \"peerDependencies\": {\n    \"@types/react\": \">=18.0.0\",\n    \"liqvid\": \"workspace:^\",\n    \"react\": \">=18.1.0\"\n  },\n  \"devDependencies\": {\n    \"css-loader\": \"^6.7.1\",\n    \"liqvid\": \"workspace:^\",\n    \"style-loader\": \"^3.3.1\",\n    \"stylus\": \"^0.58.1\",\n    \"ts-loader\": \"^9.3.1\",\n    \"webpack\": \"^5.74.0\",\n    \"webpack-cli\": \"^4.10.0\"\n  }\n}\n"
  },
  {
    "path": "packages/prompt/src/Cue.tsx",
    "content": "import * as React from \"react\";\n\nconst NS = \"lv-prompt\";\n\ninterface Props {\n  active?: boolean;\n\n  children?: React.ReactNode;\n\n  /** Name of marker when this cue should be active */\n  on: string;\n}\n\ninterface State {\n  lines: string[];\n}\n\n/** Lines to be read at a particular marker */\nexport class Cue extends React.PureComponent<Props, State> {\n  private ref: HTMLDivElement;\n\n  constructor(props: Props) {\n    super(props);\n\n    this.state = {\n      lines: null,\n    };\n  }\n\n  componentDidMount() {\n    if (!this.props.children) return;\n\n    this.ref.normalize();\n    const lines = [];\n\n    for (const node of Array.from(this.ref.childNodes)) {\n      if (!isText(node)) continue;\n\n      const blocks = node.wholeText.split(\" \");\n\n      let line = blocks.shift();\n      let text = line;\n      node.replaceData(0, node.wholeText.length, text);\n      let height = this.ref.getBoundingClientRect().height;\n\n      for (const block of blocks) {\n        node.replaceData(0, node.wholeText.length, `${text} ${block}`);\n        const newHeight = this.ref.getBoundingClientRect().height;\n\n        if (newHeight !== height) {\n          height = newHeight;\n          lines.push(line);\n          line = block;\n        } else {\n          line += ` ${block}`;\n        }\n\n        text += ` ${block}`;\n      }\n      lines.push(line);\n    }\n\n    this.setState({lines});\n  }\n\n  render() {\n    if (!this.props.children) {\n      return \" | \";\n    }\n    const spanClasses = [`${NS}-cue`];\n    const divClasses = [`${NS}-line`];\n\n    if (this.props.active) {\n      spanClasses.push(\"active\");\n      divClasses.push(\"active\");\n    }\n\n    return (\n      <React.Fragment>\n        <span className={spanClasses.join(\" \")}>{this.props.on}</span>\n\n        {this.state.lines ? (\n          this.state.lines.map((line, n) => (\n            <div className={divClasses.join(\" \")} key={n}>\n              {line}\n            </div>\n          ))\n        ) : (\n          <div className={`${NS}-measure`} ref={(ref) => (this.ref = ref)}>\n            {this.props.children}\n          </div>\n        )}\n      </React.Fragment>\n    );\n  }\n}\n\nfunction isText(node: Node): node is Text {\n  return node.nodeType === node.TEXT_NODE;\n}\n"
  },
  {
    "path": "packages/prompt/src/Prompt.tsx",
    "content": "import * as React from \"react\";\nimport {useEffect, useMemo, useRef, useState} from \"react\";\n\nimport {Utils, usePlayer} from \"liqvid\";\nconst {dragHelperReact} = Utils.interactivity;\n\nconst NS = \"lv-prompt\";\n\nimport {Cue} from \"./Cue\";\n\n/**\n * Container for {@link Cue}s\n */\nexport function Prompt(\n  props: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>,\n) {\n  const {script} = usePlayer();\n\n  const ref = useRef<HTMLDivElement>();\n  const {children, ...attrs} = props;\n\n  const [activeIndex, setActiveIndex] = useState(\n    (React.Children.toArray(children) as unknown as Cue[])\n      .map(\n        (cue: Cue) =>\n          cue.props.children &&\n          script.markerNumberOf(cue.props.on) <= script.markerIndex,\n      )\n      .lastIndexOf(true),\n  );\n\n  useEffect(() => {\n    if (!ref.current.style.left) {\n      Object.assign(ref.current.style, {\n        left: \"0%\",\n        top: \"0%\",\n      });\n    }\n\n    // subscribe to marker updates\n    script.on(\"markerupdate\", () => {\n      setActiveIndex(\n        (React.Children.toArray(children) as unknown as Cue[])\n          .map(\n            (cue: Cue) =>\n              cue.props.children &&\n              script.markerNumberOf(cue.props.on) <= script.markerIndex,\n          )\n          .lastIndexOf(true),\n      );\n    });\n  });\n\n  const dragEvents = useMemo(() => {\n    let lastX: number, lastY: number;\n    return dragHelperReact<HTMLDivElement>(\n      (e, hit) => {\n        const offset = offsetParent(ref.current);\n\n        const x = offset.left + hit.x - lastX,\n          y = offset.top + hit.y - lastY,\n          left = (x / offset.width) * 100,\n          top = (y / offset.height) * 100;\n\n        lastX = hit.x;\n        lastY = hit.y;\n\n        Object.assign(ref.current.style, {\n          left: `${left}%`,\n          top: `${top}%`,\n        });\n      },\n      (e, hit) => {\n        lastX = hit.x;\n        lastY = hit.y;\n      },\n    );\n  }, []);\n\n  return (\n    <div className={NS} {...attrs} {...dragEvents} ref={ref}>\n      {React.Children.map(props.children, (node: Cue & React.ReactElement, i) =>\n        React.cloneElement(node, {active: activeIndex === i}),\n      )}\n    </div>\n  );\n}\n\nfunction offsetParent(node: HTMLElement) {\n  if (\n    typeof node.offsetLeft !== \"undefined\" &&\n    typeof node.offsetTop !== \"undefined\"\n  ) {\n    return {\n      left: node.offsetLeft,\n      top: node.offsetTop,\n      width: node.offsetParent.getBoundingClientRect().width,\n      height: node.offsetParent.getBoundingClientRect().height,\n    };\n  }\n\n  const rect = node.getBoundingClientRect();\n\n  let parent = node;\n  while ((parent = parent.parentNode as HTMLElement)) {\n    if (![\"absolute\", \"relative\"].includes(getComputedStyle(parent).position))\n      continue;\n\n    const prect = parent.getBoundingClientRect();\n\n    return {\n      left: rect.left - prect.left,\n      top: rect.top - prect.top,\n      width: prect.width,\n      height: prect.height,\n    };\n  }\n\n  return {\n    left: rect.left,\n    top: rect.top,\n    width: innerWidth,\n    height: innerHeight,\n  };\n}\n"
  },
  {
    "path": "packages/prompt/src/index.ts",
    "content": "export {Cue} from \"./Cue\";\nexport {Prompt} from \"./Prompt\";\n"
  },
  {
    "path": "packages/prompt/style.css",
    "content": ".lv-prompt {\n  border-radius: 2px;\n  color: #fff;\n  position: absolute;\n  width: 35em;\n}\n.lv-prompt > :first-child,\n.lv-prompt > .lv-prompt-cue.active {\n  border-radius: 2px 2px 0 0;\n}\n.lv-prompt > :last-child {\n  border-radius: 0 0 2px 2px;\n}\n.lv-prompt > * {\n  display: none;\n}\n.lv-prompt > .active,\n.lv-prompt .active ~ * {\n  display: block;\n}\n.lv-prompt > :not(.active) {\n  opacity: 0.2;\n}\n.lv-prompt-cue {\n  background: #ffa500;\n  font-family: monospace;\n  font-size: .625em;\n  padding: 2px 0 2px 1em;\n}\n.lv-prompt-line {\n  padding: .1em .5em;\n}\n.lv-prompt-line:nth-of-type(odd) {\n  background: #555;\n}\n.lv-prompt-line:nth-of-type(even) {\n  background: #333;\n}\n.lv-prompt-measure {\n  display: block !important;\n  padding: .1em .5em;\n}\n"
  },
  {
    "path": "packages/prompt/style.styl",
    "content": "$ns = lv-prompt\n\n.{$ns}\n  border-radius 2px\n  color #FFF\n  position absolute\n  width 35em\n  \n  > :first-child, > .{$ns}-cue.active\n    border-radius 2px 2px 0 0\n    \n  > :last-child\n    border-radius 0 0 2px 2px\n    \n  > *\n    display none\n  \n  > .active, .active ~ *\n    display block\n    \n  > :not(.active)\n    opacity 0.2\n  \n.{$ns}-cue\n  background orange\n  font-family monospace\n  font-size .625em\n  padding 2px 0 2px 1em\n\n.{$ns}-line\n  padding .1em .5em\n  \n.{$ns}-line:nth-of-type(odd)\n    background #555\n  \n.{$ns}-line:nth-of-type(even)\n    background #333\n\n.{$ns}-measure\n  display block !important\n  padding .1em .5em\n"
  },
  {
    "path": "packages/prompt/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/react/package.json",
    "content": "{\n  \"name\": \"@liqvid/react\",\n  \"version\": \"0.0.1\",\n  \"description\": \"React surface for Liqvid\",\n  \"exports\": {\n    \".\": \"./dist/index.js\"\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/*\"]\n    }\n  },\n  \"files\": [\"dist/*\"],\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid#readme\",\n  \"dependencies\": {\n    \"@types/events\": \"^3.0.0\",\n    \"@types/react-reconciler\": \"^0.26.4\"\n  },\n  \"devDependencies\": {\n    \"events\": \"^3.3.0\",\n    \"strict-event-emitter-types\": \"^2.0.0\"\n  },\n  \"peerDependencies\": {\n    \"liqvid\": \"workspace:^\",\n    \"react\": \">=17.0.0\",\n    \"react-dom\": \">=17.0.0\"\n  },\n  \"sideEffects\": false\n}\n"
  },
  {
    "path": "packages/react/src/index.ts",
    "content": "import {useContext, useEffect, useReducer} from \"react\";\nimport {Player} from \"liqvid\";\n\nexport function usePlayer() {\n  return useContext(Player.Context);\n}\n\nexport function useKeymap() {\n  return usePlayer().keymap;\n}\n\nexport function usePlayback() {\n  return usePlayer().playback;\n}\n\n/**\n * Register a callback for time update. Returns the current time.\n */\nexport function useTime(\n  callback: (t: number) => void,\n  deps?: React.DependencyList,\n) {\n  const {playback} = useContext(Player.Context);\n\n  useEffect(() => {\n    playback.hub.on(\"seek\", callback);\n    playback.hub.on(\"timeupdate\", callback);\n    callback(playback.currentTime);\n\n    return () => {\n      playback.hub.off(\"seek\", callback);\n      playback.hub.off(\"timeupdate\", callback);\n    };\n  }, deps);\n\n  return playback.currentTime;\n}\n\nexport function combineRefs<T>(...args: React.Ref<T>[]) {\n  return (o: T) => {\n    for (const ref of args) {\n      if (typeof ref === \"function\") {\n        ref(o);\n      } else if (ref === null) {\n      } else if (typeof ref === \"object\" && ref.hasOwnProperty(\"current\")) {\n        (ref as React.MutableRefObject<T>).current = o;\n      }\n    }\n  };\n}\n\nexport function useForceUpdate() {\n  return useReducer((c: boolean) => !c, false)[1];\n}\n"
  },
  {
    "path": "packages/react/src/three.tsx",
    "content": "import {Canvas, useThree} from \"@react-three/fiber\";\nimport {ResizeObserver} from \"@juggle/resize-observer\";\nimport {Player, usePlayer} from \"liqvid\";\n\nexport function ThreeCanvas(props: React.ComponentProps<typeof Canvas>) {\n  return (\n    <Canvas resize={{polyfill: ResizeObserver}} {...props}>\n      <Player.Context.Provider value={usePlayer()}>\n        <Fixes />\n        {props.children}\n      </Player.Context.Provider>\n    </Canvas>\n  );\n}\n\nimport {useEffect} from \"react\";\n\nfunction Fixes(): null {\n  const {gl} = useThree();\n  useEffect(() => {\n    gl.domElement.setAttribute(\"touch-action\", \"none\");\n    gl.domElement.addEventListener(\"mouseup\", Player.preventCanvasClick);\n  }, []);\n  return null;\n}\n"
  },
  {
    "path": "packages/react/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"module\": \"commonjs\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/react-three/README.md",
    "content": "# @liqvid/react-three\n\nThis 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.\n"
  },
  {
    "path": "packages/react-three/package.json",
    "content": "{\n  \"name\": \"@liqvid/react-three\",\n  \"version\": \"2.0.0\",\n  \"description\": \"@react-three integration for Liqvid\",\n  \"main\": \"./dist/index.cjs\",\n  \"module\": \"./dist/index.mjs\",\n  \"typings\": \"./dist/index.d.ts\",\n  \"files\": [\"dist/*\"],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:cjs && pnpm build:esm\",\n    \"build:clean\": \"rm -fr dist\",\n    \"build:cjs\": \"tsc --module commonjs && mv dist/index.js dist/index.cjs\",\n    \"build:esm\": \"tsc --module esnext && mv dist/index.js dist/index.mjs\",\n    \"lint\": \"eslint --ext ts,tsx --fix src\",\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/react-three\",\n  \"dependencies\": {\n    \"@juggle/resize-observer\": \"^3.3.1\",\n    \"@react-three/drei\": \"^9.8.1\",\n    \"@types/events\": \"^3.0.0\",\n    \"@types/react-reconciler\": \"^0.26.7\"\n  },\n  \"devDependencies\": {\n    \"@react-three/fiber\": \"^8.0.16\",\n    \"@types/three\": \"^0.140.0\",\n    \"events\": \"^3.3.0\",\n    \"liqvid\": \"workspace:^\",\n    \"strict-event-emitter-types\": \"^2.0.0\",\n    \"three\": \"^0.140.2\"\n  },\n  \"peerDependencies\": {\n    \"@react-three/fiber\": \"^8.0.16\",\n    \"liqvid\": \"workspace:^\",\n    \"react\": \">=18.1.0\",\n    \"react-dom\": \">=18.1.0\"\n  },\n  \"sideEffects\": false,\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/react-three/src/index.tsx",
    "content": "import {ResizeObserver} from \"@juggle/resize-observer\";\nimport {useContextBridge} from \"@react-three/drei/core/useContextBridge.js\";\nimport {Canvas as ThreeCanvas, useThree} from \"@react-three/fiber\";\nimport {Player, PlaybackContext, KeymapContext} from \"liqvid\";\nimport {useEffect} from \"react\";\n\n/** Default affordances: click and arrow keys */\nconst defaultAffords = \"click keys(ArrowUp,ArrowDown,ArrowLeft,ArrowRight)\";\n\n/**\n * Liqvid-aware Canvas component @react-three/fiber\n */\nexport function Canvas(\n  props: React.ComponentProps<typeof ThreeCanvas & {\"data-affords\"?: string}>,\n) {\n  const ContextBridge = useContextBridge(\n    Player.Context,\n    PlaybackContext,\n    KeymapContext,\n  );\n  return (\n    <ThreeCanvas resize={{polyfill: ResizeObserver}} {...props}>\n      <ContextBridge>\n        <Fixes {...props} />\n        {props.children}\n      </ContextBridge>\n    </ThreeCanvas>\n  );\n}\n\nfunction Fixes(props: {\n  \"data-affords\"?: string;\n}): null {\n  const {gl} = useThree();\n  useEffect(() => {\n    const affords = props[\"data-affords\"] ?? defaultAffords;\n    if (affords) {\n      gl.domElement.setAttribute(\"data-affords\", affords);\n    }\n    gl.domElement.style.touchAction = \"none\";\n  }, []);\n  return null;\n}\n"
  },
  {
    "path": "packages/react-three/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/recording/CHANGELOG.md",
    "content": "## 0.2.4 (Feb 4, 2025)\n\n- work with React server-side rendering\n\n## 0.2.0 (Jan 5, 2024)\n\n- support/require React 18\n\n## 0.0.7 (Dec 31, 2023)\n\n- fix `null` bug in `compress()`\n"
  },
  {
    "path": "packages/recording/README.md",
    "content": "# @liqvid/recording.\n\nRecording functionality for [`Liqvid`](https://liqvidjs.org/). Documentation at https://liqvidjs.org/docs/plugins/recording.\n"
  },
  {
    "path": "packages/recording/package.json",
    "content": "{\n  \"name\": \"@liqvid/recording\",\n  \"version\": \"0.2.5\",\n  \"description\": \"Recording functionality for Liqvid\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    },\n    \"./style.css\": \"./dist/style.css\"\n  },\n  \"typings\": \"./dist/types/index.d.ts\",\n  \"files\": [\"dist/*\"],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:css && pnpm build:js && pnpm build:postclean\",\n    \"build:css\": \"stylus -o dist/style.css styl/style.styl; stylus -c -o dist/style.min.css styl/style.styl\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"pnpm build:js:esm; pnpm build:js:cjs; pnpm build:js:fix\",\n    \"build:js:cjs\": \"tsc --module commonjs --outDir dist/cjs\",\n    \"build:js:esm\": \"tsc --module esnext --outDir dist/esm\",\n    \"build:js:fix\": \"node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"biome check --fix\",\n    \"css\": \"stylus -c -w -o dist/style.css styl/recording.styl\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/recording#readme\",\n  \"devDependencies\": {\n    \"liqvid\": \"workspace:^\",\n    \"nib\": \"^1.1.2\",\n    \"stylus\": \"^0.56.0\"\n  },\n  \"dependencies\": {\n    \"@liqvid/keymap\": \"workspace:^\",\n    \"@liqvid/utils\": \"workspace:^\",\n    \"@types/events\": \"^3.0.0\",\n    \"events\": \"^3.3.0\",\n    \"strict-event-emitter-types\": \"^2.0.0\"\n  },\n  \"peerDependencies\": {\n    \"liqvid\": \"workspace:^\",\n    \"react\": \">=18\"\n  },\n  \"peerDependenciesMeta\": {\n    \"liqvid\": {\n      \"optional\": true\n    }\n  }\n}\n"
  },
  {
    "path": "packages/recording/src/Control.tsx",
    "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useReducer,\n  useRef,\n  useState,\n} from \"react\";\n\nimport {Keymap} from \"@liqvid/keymap\";\nimport {useKeymap} from \"@liqvid/keymap/react\";\nimport {onClick, useForceUpdate} from \"@liqvid/utils/react\";\n\nimport {RecordingManager} from \"./RecordingManager\";\nimport type {RecorderPlugin} from \"./types\";\n\nimport RecordingRow from \"./RecordingRow\";\nimport {Recorder} from \"./recorder\";\n\ninterface Props {\n  manager?: RecordingManager;\n  plugins?: RecorderPlugin<unknown, unknown>[];\n}\n\ninterface Action {\n  command: keyof State;\n  seq: string;\n}\n\ninterface State {\n  start: string;\n  pause: string;\n  discard: string;\n}\n\n/**\n * Liqvid recording control.\n */\nexport function RecordingControl(props: Props) {\n  const keymap = useKeymap();\n\n  const [recordings, setRecordings] = useState([]);\n  const forceUpdate = useForceUpdate();\n\n  // recording manager\n  const manager = useRef<RecordingManager>();\n\n  useEffect(() => {\n    manager.current = props.manager ?? new RecordingManager();\n\n    const eventNames = [\"finalize\", \"start\", \"pause\", \"resume\"] as const;\n\n    for (const eventName of eventNames) {\n      manager.current.on(eventName, forceUpdate);\n    }\n\n    return () => {\n      for (const eventName of eventNames) {\n        manager.current.off(eventName, forceUpdate);\n      }\n    };\n  }, [forceUpdate, props.manager]);\n\n  // active plugins\n  const activePlugins = useRef<{[key: string]: boolean}>(null);\n  if (activePlugins.current === null) {\n    activePlugins.current = {};\n\n    for (const plugin of props.plugins) {\n      activePlugins.current[plugin.key] = false;\n    }\n  }\n\n  // plugins dictionary\n  const [pluginsByKey] = useState(() => {\n    const dict: Record<string, RecorderPlugin<unknown, unknown>> = {};\n    for (const plugin of props.plugins) {\n      dict[plugin.key] = plugin;\n    }\n    return dict;\n  });\n\n  /* commands */\n  const start = useCallback(() => {\n    const {active, beginRecording, endRecording} = manager.current;\n    if (active) {\n      endRecording().then((recording: Record<string, unknown>) => {\n        recording.duration = manager.current.duration;\n        setRecordings((prev) => prev.concat(recording));\n      });\n    } else {\n      const recorders: Record<string, Recorder<unknown, unknown>> = {};\n      for (const plugin of props.plugins) {\n        if (activePlugins.current[plugin.key]) {\n          recorders[plugin.key] = plugin.recorder;\n        }\n      }\n      beginRecording(recorders);\n    }\n  }, [props.plugins]);\n\n  const pause = useCallback(() => {\n    const {active, paused, pauseRecording, resumeRecording} = manager.current;\n    if (active) {\n      paused ? resumeRecording() : pauseRecording();\n    }\n  }, []);\n\n  const discard = useCallback(async () => {\n    const {active, endRecording} = manager.current;\n    if (active) {\n      const listeners = manager.current.listeners(\"finalize\") as Parameters<\n        typeof manager.current.on\n      >[1][];\n      for (const listener of listeners) {\n        manager.current.off(\"finalize\", listener);\n      }\n      try {\n        await endRecording();\n      } catch (e) {\n        console.error(e);\n      }\n\n      for (const listener of listeners) {\n        manager.current.on(\"finalize\", listener);\n      }\n\n      forceUpdate();\n    }\n  }, [forceUpdate]);\n\n  /* keyboard controls */\n  const callbacks: Record<keyof State, (e: KeyboardEvent) => void> = useMemo(\n    () => ({start, pause, discard}),\n    [discard, pause, start],\n  );\n\n  const reducer: React.Reducer<State, Action> = useCallback((state, action) => {\n    // return new state\n    return {\n      ...state,\n      [action.command]: action.seq,\n    };\n  }, []);\n\n  const [state, dispatch] = useReducer(reducer, null, () => ({\n    start: isMac() ? \"Alt+Meta+2\" : \"Ctrl+Alt+2\",\n    pause: isMac() ? \"Alt+Meta+3\" : \"Ctrl+Alt+3\",\n    discard: isMac() ? \"Alt+Meta+4\" : \"Ctrl+Alt+4\",\n  }));\n\n  // bind\n  useEffect(() => {\n    for (const key of Object.keys(state) as (keyof State)[]) {\n      keymap.bind(state[key], callbacks[key]);\n    }\n\n    return () => {\n      for (const key of Object.keys(state) as (keyof State)[]) {\n        keymap.unbind(state[key], callbacks[key]);\n      }\n    };\n  }, [callbacks, keymap, state]);\n\n  // onBlur event, triggers rebind\n  const onBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {\n    e.preventDefault();\n\n    const name = e.currentTarget.getAttribute(\"name\") as keyof State;\n\n    // bind sequence\n    const seq = e.currentTarget.dataset.value;\n    dispatch({command: name, seq});\n  }, []);\n\n  // display shortcut sequence\n  const identifyKey = useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      e.preventDefault();\n\n      const seq = Keymap.identify(e as unknown as KeyboardEvent);\n      e.currentTarget.dataset.value = seq;\n      e.currentTarget.value = fmtSeq(seq);\n    },\n    [],\n  );\n\n  // warn before closing if recordings exist\n  const warn = useRef(false);\n  warn.current = recordings.length > 0;\n\n  useEffect(() => {\n    window.addEventListener(\"beforeunload\", (e: BeforeUnloadEvent) => {\n      if (warn.current) e.returnValue = \"You have recording data\";\n    });\n  }, []);\n\n  // show/hide control pane\n  const [paneOpen, setPaneOpen] = useState(false);\n  const togglePane = useMemo(\n    () =>\n      onClick(() => {\n        setPaneOpen((prev) => !prev);\n      }),\n    [],\n  );\n\n  const dialogStyle = {\n    display: paneOpen ? \"block\" : \"none\",\n  };\n\n  // toggle plugin\n  const setActive = useMemo(\n    () =>\n      onClick<SVGSVGElement>((e) => {\n        const key = e.currentTarget.dataset.plugin;\n        activePlugins.current[key] = !activePlugins.current[key];\n        forceUpdate();\n      }),\n    [forceUpdate],\n  );\n\n  /* render */\n  const commands: [string, keyof State][] = [\n    [\"Start/Stop recording\", \"start\"],\n    [\"Pause recording\", \"pause\"],\n    [\"Discard recording\", \"discard\"],\n  ];\n\n  return (\n    <div id=\"lv-recording\">\n      <div id=\"lv-recording-dialog\" style={dialogStyle}>\n        <table id=\"lv-recording-configuration\">\n          <tbody>\n            <tr>\n              <th colSpan={2}>Commands</th>\n            </tr>\n            {commands.map(([desc, key]) => (\n              <tr key={key}>\n                <th scope=\"row\">{desc}</th>\n                <td>\n                  <input\n                    onBlur={onBlur}\n                    readOnly\n                    onKeyDown={identifyKey}\n                    className=\"shortcut\"\n                    name={key}\n                    type=\"text\"\n                    value={fmtSeq(state[key])}\n                  />\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n\n        <h3>Configuration</h3>\n        {props.plugins.map((plugin) => {\n          const classNames = [\"recorder-plugin-icon\"];\n\n          if (activePlugins.current[plugin.key]) classNames.push(\"active\");\n\n          const styles: React.CSSProperties = {};\n          const enabled =\n            typeof plugin.enabled === \"undefined\" || plugin.enabled();\n          if (!enabled) {\n            styles.opacity = 0.3;\n          }\n\n          return (\n            <div\n              className=\"recorder-plugin\"\n              key={plugin.key}\n              title={plugin.title}\n              style={styles}\n            >\n              <svg\n                className={classNames.join(\" \")}\n                height=\"36\"\n                width=\"36\"\n                viewBox=\"0 0 100 100\"\n                data-plugin={plugin.key}\n                {...(enabled ? setActive : {})}\n              >\n                <rect\n                  height=\"100\"\n                  width=\"100\"\n                  fill={activePlugins.current[plugin.key] ? \"red\" : \"#222\"}\n                />\n                {plugin.icon}\n              </svg>\n              <span className=\"recorder-plugin-name\">{plugin.name}</span>\n            </div>\n          );\n        })}\n\n        <h3>Saved data</h3>\n        <ol className=\"recordings\">\n          {recordings.map((recording, i) => (\n            <RecordingRow\n              key={i}\n              data={recording}\n              pluginsByKey={pluginsByKey}\n            />\n          ))}\n        </ol>\n      </div>\n      <svg height=\"36\" width=\"36\" viewBox=\"-50 -50 100 100\" {...togglePane}>\n        <circle\n          cx=\"0\"\n          cy=\"0\"\n          r=\"35\"\n          stroke=\"white\"\n          strokeWidth=\"5\"\n          fill={\n            manager.current?.active\n              ? manager.current?.paused\n                ? \"yellow\"\n                : \"red\"\n              : \"#666\"\n          }\n        />\n      </svg>\n    </div>\n  );\n}\n\n/** Format key sequences with special characters on Mac */\nfunction fmtSeq(str: string) {\n  if (!isMac()) return str;\n  if (str === void 0) return str;\n  return str\n    .split(\"+\")\n    .map((k) => {\n      if (k === \"Ctrl\") return \"^\";\n      else if (k === \"Alt\") return \"⌥\";\n      if (k === \"Shift\") return \"⇧\";\n      if (k === \"Meta\") return \"⌘\";\n      return k;\n    })\n    .join(\"\");\n}\n\nfunction isMac() {\n  return (\n    typeof globalThis.navigator !== \"undefined\" &&\n    navigator.platform === \"MacIntel\"\n  );\n}\n"
  },
  {
    "path": "packages/recording/src/RecordingManager.ts",
    "content": "import {EventEmitter} from \"events\";\nimport {bind} from \"@liqvid/utils/misc\";\nimport type StrictEventEmitter from \"strict-event-emitter-types\";\n\nimport type {IntransigentReturn, Recorder} from \"./recorder\";\n\ninterface EventTypes {\n  cancel: void;\n  capture: (key: string, data: unknown) => void;\n  finalize: (key: string, data: unknown) => void;\n  pause: void;\n  resume: void;\n  start: void;\n}\n\n/**\n * Class for managing recording sessions.\n */\nexport class RecordingManager extends (EventEmitter as unknown as new () => StrictEventEmitter<\n  EventEmitter,\n  EventTypes\n>) {\n  /** Whether recording is currently in progress. */\n  active: boolean;\n\n  /** Duration of recording. */\n  duration: number;\n\n  /** Whether recording is currently paused. */\n  paused: boolean;\n\n  /** Time when recording began. */\n  private baseTime: number;\n\n  private captureData: {\n    [key: string]: unknown[];\n  };\n\n  private plugins: Record<string, Recorder<unknown, unknown>>;\n\n  private intransigentRecorder: Recorder<unknown, unknown>;\n\n  /** Time when last paused. */\n  private lastPauseTime: number;\n\n  /** Total duration that recording has been paused. */\n  private pauseTime: number;\n\n  constructor() {\n    super();\n\n    this.captureData = {};\n\n    this.setMaxListeners(0);\n\n    this.paused = false;\n    this.active = false;\n\n    bind(this, [\n      \"beginRecording\",\n      \"endRecording\",\n      \"pauseRecording\",\n      \"resumeRecording\",\n      \"capture\",\n    ]);\n  }\n\n  /**\n   * Begin recording.\n   *\n   * @emits start\n   */\n  beginRecording(plugins: Record<string, Recorder<unknown, unknown>>): void {\n    this.plugins = plugins;\n\n    // initialize\n    this.pauseTime = 0;\n    this.intransigentRecorder = void 0;\n\n    // dependency injection for plugins\n    for (const key in this.plugins) {\n      const recorder = this.plugins[key];\n\n      recorder.provide({\n        push: (value: unknown) => this.capture(key, value),\n        manager: this,\n      });\n\n      this.captureData[key] = [];\n\n      if (recorder.intransigent) {\n        if (this.intransigentRecorder)\n          throw new Error(\"At most one intransigent recorder is allowed\");\n        this.intransigentRecorder = recorder;\n      }\n    }\n\n    // call this as close as possible to beginRecording() to minimize \"lag\"\n    this.baseTime = performance.now();\n    for (const key in this.plugins) {\n      this.plugins[key].beginRecording();\n    }\n\n    this.paused = false;\n    this.active = true;\n\n    this.emit(\"start\");\n  }\n\n  /**\n   * Commit a piece of recording data.\n   * @param key Key for recording source.\n   * @param value Data to record.\n   *\n   * @emits capture\n   */\n  capture(key: string, value: unknown): void {\n    this.captureData[key].push(value);\n\n    this.emit(\"capture\", key, value);\n  }\n\n  /**\n   * End recording and collect finalized data from recorders.\n   *\n   * @emits finalize\n   */\n  async endRecording(): Promise<unknown> {\n    const endTime = this.getTime();\n    this.duration = endTime;\n    const recording: Record<string, unknown> = {};\n\n    let startDelay = 0,\n      stopDelay = 0;\n\n    let promise;\n\n    // stop intransigentRecorder\n    if (this.intransigentRecorder) {\n      promise =\n        this.intransigentRecorder.endRecording() as Promise<IntransigentReturn>;\n    }\n\n    // stop other recorders\n    for (const key in this.plugins) {\n      if (this.plugins[key] === this.intransigentRecorder) continue;\n      this.plugins[key].endRecording();\n    }\n\n    // get start/stop delays from intransigentRecorder\n    if (this.intransigentRecorder) {\n      try {\n        const [startTime, stopTime] = await promise;\n        startDelay = startTime;\n        stopDelay = stopTime - endTime;\n        this.duration = this.duration + stopDelay - startDelay;\n      } catch (e) {\n        startDelay = 0;\n        stopDelay = 0;\n        console.error(e);\n      }\n    }\n\n    // finalize\n    for (const key in this.plugins) {\n      recording[key] = this.plugins[key].finalizeRecording(\n        this.captureData[key],\n        startDelay,\n        stopDelay,\n      );\n      this.emit(\"finalize\", key, recording[key]);\n    }\n\n    this.active = false;\n\n    this.emit(\"finalize\", undefined, undefined);\n\n    return recording;\n  }\n\n  /** Get current recording time. */\n  getTime(): number {\n    return performance.now() - this.baseTime - this.pauseTime;\n  }\n\n  /**\n   * Pause recording.\n   *\n   * @emits pause\n   */\n  pauseRecording(): void {\n    this.lastPauseTime = performance.now();\n\n    for (const key in this.plugins) {\n      this.plugins[key].pauseRecording();\n    }\n\n    this.paused = true;\n    this.emit(\"pause\");\n  }\n\n  /**\n   * Resume recording from paused state.\n   *\n   * @emits resume\n   */\n  resumeRecording(): void {\n    this.pauseTime += performance.now() - this.lastPauseTime;\n\n    for (const key in this.plugins) {\n      this.plugins[key].resumeRecording();\n    }\n\n    this.paused = false;\n    this.emit(\"resume\");\n  }\n}\n"
  },
  {
    "path": "packages/recording/src/RecordingRow.tsx",
    "content": "import {formatTimeMs} from \"@liqvid/utils/time\";\nimport {useCallback, useState} from \"react\";\n\nimport type {RecorderPlugin} from \"./types\";\n\ninterface Props {\n  data: {\n    duration: number;\n    [key: string]: unknown;\n  };\n  pluginsByKey: {\n    [key: string]: RecorderPlugin<unknown, unknown>;\n  };\n}\n\nexport default function RecordingRow(props: Props) {\n  const [name, setName] = useState(\"Untitled\");\n\n  const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    setName(e.target.value);\n  }, []);\n\n  const {data, pluginsByKey} = props;\n\n  return (\n    <li className=\"recording-row\">\n      <input\n        className=\"recording-name\"\n        onChange={onChange}\n        type=\"text\"\n        value={name}\n      />\n      <table className=\"recording-results\">\n        <caption>\n          Duration: {data.duration} ({formatTimeMs(data.duration)})\n        </caption>\n        <tbody>\n          {Object.keys(data).map((pluginKey) => {\n            if (pluginKey === \"duration\") return null;\n            const plugin = pluginsByKey[pluginKey],\n              SaveComponent = plugin.saveComponent;\n\n            return (\n              <tr key={pluginKey}>\n                <th key=\"head\" scope=\"row\" title={plugin.name}>\n                  <svg\n                    className=\"recorder-plugin-icon\"\n                    height=\"36\"\n                    width=\"36\"\n                    viewBox=\"0 0 100 100\"\n                  >\n                    <rect height=\"100\" width=\"100\" fill=\"#222\" />\n                    {plugin.icon}\n                  </svg>\n                </th>\n                <td key=\"cell\">\n                  <SaveComponent data={data[pluginKey]} />\n                </td>\n              </tr>\n            );\n          })}\n        </tbody>\n      </table>\n    </li>\n  );\n}\n"
  },
  {
    "path": "packages/recording/src/index.ts",
    "content": "export type {RecorderPlugin} from \"./types\";\nexport {AudioRecorder, AudioRecording} from \"./recorders/audio-recording\";\nexport {MarkerRecorder, MarkerRecording} from \"./recorders/marker-recording\";\nexport {Recorder} from \"./recorder\";\nexport {RecordingControl} from \"./Control\";\nexport {RecordingManager} from \"./RecordingManager\";\nexport {ReplayDataRecorder, compress} from \"./recorders/replay-data-recorder\";\nexport {VideoRecorder, VideoRecording} from \"./recorders/video-recording\";\n"
  },
  {
    "path": "packages/recording/src/recorder.ts",
    "content": "import type {RecordingManager} from \"./RecordingManager.js\";\n\nexport type IntransigentReturn = [number, number];\n\n/**\n * Abstract class for recording interactions.\n */\nexport abstract class Recorder<T = unknown, F = T[]> {\n  protected manager: RecordingManager;\n\n  /**\n   * A recorder is intransigent if it cannot be started immediately (e.g. AudioRecorder).\n   */\n  intransigent = false;\n\n  /** Begin recording. */\n  beginRecording(): void {}\n\n  /** Pause recording. */\n  pauseRecording(): void {}\n\n  /** Resume recording from paused. */\n  resumeRecording(): void {}\n\n  /** End recording. */\n  endRecording(): Promise<IntransigentReturn> | void {}\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  finalizeRecording(data: T[], startDelay = 0, stopDelay = 0): F {\n    return data as unknown as F;\n  }\n\n  push: (value: T) => void;\n\n  provide({\n    push,\n    manager,\n  }: {\n    push: (value: T) => void;\n    manager: RecordingManager;\n  }) {\n    this.push = push;\n    this.manager = manager;\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  getUpdate(data: T[], lastDuration: number) {}\n}\n"
  },
  {
    "path": "packages/recording/src/recorders/audio-recording.tsx",
    "content": "import {isClient} from \"@liqvid/utils/ssr\";\n\nimport {type IntransigentReturn, Recorder} from \"../recorder\";\nimport type {RecorderPlugin} from \"../types\";\n\nconst icon = (\n  <g transform=\"scale(0.126261032057) translate(164.575)\">\n    <g stroke=\"#FFF\" transform=\"translate(-140.62 -173.21)\">\n      <path\n        d=\"m568.57 620.93c0 116.77-94.66 211.43-211.43 211.43s-211.43-94.66-211.43-211.43v-0.00001\"\n        fillOpacity=\"0\"\n        transform=\"translate(14.904)\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"20\"\n      />\n      <path\n        d=\"m568.57 620.93c0 116.77-94.66 211.43-211.43 211.43s-211.43-94.66-211.43-211.43v-0.00001\"\n        fillOpacity=\"0\"\n        transform=\"translate(14.904)\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"40\"\n      />\n      <path d=\"m372.05 832.36v114.29\" strokeWidth=\"30\" fill=\"none\" />\n      <path\n        fill=\"#FFF\"\n        d=\"m197.14 920.93c0.00001-18.935 59.482-34.286 132.86-34.286 73.375 0 132.86 15.35 132.86 34.286z\"\n        transform=\"translate(42.047 34.286)\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"20\"\n      />\n      <path\n        fill=\"#FFF\"\n        strokeWidth=\"21.455\"\n        strokeLinecap=\"round\"\n        d=\"m372.06 183.94c-77.019-0.00001-139.47 62.45-139.47 139.47v289.62c0 77.019 62.45 139.47 139.47 139.47 77.019 0 139.44-62.45 139.44-139.47v-289.62c0-77.02-62.42-139.47-139.44-139.47z\"\n      />\n    </g>\n  </g>\n);\n\nexport class AudioRecorder extends Recorder<Blob, Blob> {\n  private mediaRecorder: MediaRecorder;\n  private promise: Promise<IntransigentReturn>;\n\n  stream: MediaStream;\n  private requested = false;\n\n  intransigent = true;\n\n  beginRecording() {\n    if (!this.stream) throw new Error(\"Navigator stream not available\");\n\n    this.promise = new Promise(async (resolve) => {\n      // record the audio\n      this.mediaRecorder = new MediaRecorder(this.stream, {\n        mimeType: \"audio/webm\",\n      });\n\n      // subscribe to events\n      this.mediaRecorder.addEventListener(\"dataavailable\", (e) => {\n        this.push(e.data);\n      });\n\n      let startDelay: number;\n      this.mediaRecorder.addEventListener(\"start\", () => {\n        startDelay = this.manager.getTime();\n      });\n\n      this.mediaRecorder.addEventListener(\"stop\", () => {\n        resolve([startDelay, this.manager.getTime()]);\n      });\n\n      this.mediaRecorder.start();\n    });\n  }\n\n  pauseRecording() {\n    this.mediaRecorder.pause();\n  }\n\n  resumeRecording() {\n    this.mediaRecorder.resume();\n  }\n\n  async endRecording() {\n    this.mediaRecorder.stop();\n    return this.promise;\n  }\n\n  finalizeRecording(chunks: Blob[]) {\n    return new Blob(chunks, {type: \"audio/webm\"});\n  }\n\n  requestRecording(constraints: MediaStreamConstraints = {audio: true}) {\n    // be idempotent\n    if (this.requested) return;\n\n    const request = async () => {\n      // Only need to do this once...\n      window.removeEventListener(\"click\", request);\n\n      try {\n        this.stream = await navigator.mediaDevices.getUserMedia(constraints);\n      } catch (e) {\n        // User said no or browser rejected request due to insecure context\n        console.log(\"no recording allowed\");\n      }\n    };\n\n    // Need user interaction to request media\n    window.addEventListener(\"click\", request);\n    this.requested = true;\n  }\n}\n\nexport function AudioSaveComponent(props: {data: Blob}) {\n  return (\n    <>\n      {props.data ? (\n        <a download=\"audio.webm\" href={URL.createObjectURL(props.data)}>\n          Download Audio\n        </a>\n      ) : (\n        \"Audio not yet available\"\n      )}\n    </>\n  );\n}\n\nconst recorder = new AudioRecorder();\nexport const AudioRecording: RecorderPlugin<Blob, Blob, AudioRecorder> = {\n  enabled: () => {\n    if (typeof recorder.stream === \"undefined\") {\n      if (isClient) recorder.requestRecording();\n      return false;\n    }\n    return true;\n  },\n  icon,\n  key: \"audio\",\n  name: \"Audio\",\n  recorder,\n  saveComponent: AudioSaveComponent,\n  title: \"Record audio\",\n};\n"
  },
  {
    "path": "packages/recording/src/recorders/marker-recording.tsx",
    "content": "import {bind} from \"@liqvid/utils/misc\";\nimport {formatTimeMs} from \"@liqvid/utils/time\";\nimport type {Script} from \"liqvid\";\nimport {Recorder} from \"../recorder\";\nimport type {RecorderPlugin} from \"../types\";\n\ntype Marker = [string, number];\ntype MarkerFormatted = [string, string];\n\nconst icon = (\n  <text\n    fill=\"#FFF\"\n    fontFamily=\"Helvetica\"\n    fontSize=\"75\"\n    textAnchor=\"middle\"\n    x=\"50\"\n    y=\"75\"\n  >\n    M\n  </text>\n);\n\nexport class MarkerRecorder extends Recorder<Marker, MarkerFormatted[]> {\n  private lastTime: number;\n  script: Script;\n\n  constructor() {\n    super();\n    bind(this, [\"onMarkerUpdate\"]);\n  }\n\n  beginRecording() {\n    this.lastTime = 0;\n    this.script.on(\"markerupdate\", this.onMarkerUpdate);\n  }\n\n  endRecording() {\n    this.script.off(\"markerupdate\", this.onMarkerUpdate);\n    this.captureMarker(this.script.markerName);\n  }\n\n  finalizeRecording(data: Marker[], startDelay: number, stopDelay: number) {\n    data[0][1] -= startDelay;\n    data[data.length - 1][1] += stopDelay;\n\n    return data.map((cue) => [cue[0], formatTimeMs(cue[1])] as MarkerFormatted);\n  }\n\n  onMarkerUpdate(prevIndex: number) {\n    if (this.manager.paused) return;\n\n    this.captureMarker(this.script.markers[prevIndex][0]);\n  }\n\n  captureMarker(markerName: string) {\n    const t = this.manager.getTime();\n    this.push([markerName, t - this.lastTime]);\n\n    this.lastTime = t;\n  }\n}\n\nexport function MarkerSaveComponent(props: {data: MarkerFormatted[]}) {\n  return (\n    <>\n      <textarea readOnly value={format(props.data)}></textarea>\n    </>\n  );\n}\n\nexport const MarkerRecording: RecorderPlugin<\n  Marker,\n  MarkerFormatted[],\n  MarkerRecorder\n> = {\n  icon,\n  key: \"markers\",\n  name: \"Markers\",\n  recorder: new MarkerRecorder(),\n  saveComponent: MarkerSaveComponent,\n};\n\nfunction format(data: unknown) {\n  return JSON.stringify(data, null, 2).replace(\n    /\\[\\s+\"(.+?)\",\\s+\"(.+?)\"\\s+\\]/g,\n    '[\"$1\", \"$2\"]',\n  );\n}\n"
  },
  {
    "path": "packages/recording/src/recorders/replay-data-recorder.ts",
    "content": "import type {ReplayData} from \"@liqvid/utils/replay-data\";\nimport {Recorder} from \"../recorder\";\n\nexport class ReplayDataRecorder<T> extends Recorder<\n  [number, T],\n  ReplayData<T>\n> {\n  private duration: number;\n\n  constructor() {\n    super();\n    this.duration = 0;\n  }\n\n  beginRecording(): void {\n    this.duration = 0;\n  }\n\n  finalizeRecording(\n    data: ReplayData<T>,\n    // startDelay = 0,\n    // stopDelay = 0\n  ): ReplayData<T> {\n    // for (let sum = 0, i = 0; i < data.length && sum < startDelay; ++i) {\n    //   const dur = data[i][0];\n\n    //   if (dur === 0) {\n    //     continue;\n    //   }\n    //   if (sum + dur >= startDelay) {\n    //     data[i][0] -= startDelay - sum;\n    //     break;\n    //   }\n    //   sum += dur;\n    //   // data.splice(i, 1);\n    //   --i;\n    // }\n    // console.log(JSON.stringify(data, null, 2));\n\n    return compress(data);\n  }\n\n  capture(time = this.manager.getTime(), data: T): void {\n    if (time - this.duration < 0) {\n      // console.error(time, this.duration, data);\n    }\n    this.push([time - this.duration, data]);\n    this.duration = time;\n  }\n}\n\n/**\n * Truncate numerical precision to reduce filesize.\n * @param o Data to compress.\n * @param precision Number of decimal points to include.\n */\nexport function compress<T>(o: T, precision = 2): T {\n  switch (typeof o) {\n    case \"object\":\n      if (o instanceof Array) {\n        return o.map((val) => compress(val, precision)) as T & unknown[];\n      }\n      if (o === null) {\n        return o;\n      }\n      return Object.fromEntries(\n        (Object.keys(o) as (keyof typeof o)[]).map((key) => [\n          key,\n          compress(o[key], precision),\n        ]),\n      ) as Record<string, unknown> & T;\n    case \"number\":\n      return parseFloat(o.toFixed(precision)) as T & number;\n    default:\n      return o;\n  }\n}\n"
  },
  {
    "path": "packages/recording/src/recorders/video-recording.tsx",
    "content": "import {isClient} from \"@liqvid/utils/ssr\";\n\nimport {type IntransigentReturn, Recorder} from \"../recorder\";\nimport type {RecorderPlugin} from \"../types\";\n\nconst icon = (\n  <path\n    fill=\"#FFF\"\n    d=\"M35.113 14.703a4.558 4.558 0 0 0-4.568 4.568v2.338h-11.29A13.146 13.146 0 0 0 6.082 34.787v37.018a13.142 13.142 0 0 0 13.173 13.172H80.74a13.147 13.147 0 0 0 13.178-13.172V34.787A13.146 13.146 0 0 0 80.74 21.61H69.455v-2.338a4.558 4.558 0 0 0-4.568-4.568H35.113ZM50 31.196c12.18 0 22.103 9.917 22.103 22.097 0 12.18-9.923 22.103-22.103 22.103-12.181 0-22.103-9.923-22.103-22.103 0-12.18 9.922-22.097 22.103-22.097Zm-30.073.835a4.59 4.59 0 0 1 4.59 4.59h.006a4.59 4.59 0 1 1-4.595-4.59ZM50 35.536a17.721 17.721 0 0 0-17.757 17.757A17.722 17.722 0 0 0 50 71.05a17.723 17.723 0 0 0 17.757-17.757A17.722 17.722 0 0 0 50 35.536Z\"\n  />\n);\n\nexport class VideoRecorder extends Recorder<Blob, Blob> {\n  private mediaRecorder: MediaRecorder;\n  private promise: Promise<IntransigentReturn>;\n\n  stream: MediaStream;\n  private requested = false;\n\n  intransigent = true;\n\n  beginRecording() {\n    if (!this.stream) throw new Error(\"Navigator stream not available\");\n\n    this.promise = new Promise(async (resolve) => {\n      // record the video\n      this.mediaRecorder = new MediaRecorder(this.stream, {\n        mimeType: \"video/webm\",\n      });\n\n      // subscribe to events\n      this.mediaRecorder.addEventListener(\"dataavailable\", (e) => {\n        this.push(e.data);\n      });\n\n      let startDelay: number;\n      this.mediaRecorder.addEventListener(\"start\", () => {\n        startDelay = this.manager.getTime();\n      });\n\n      this.mediaRecorder.addEventListener(\"stop\", () => {\n        resolve([startDelay, this.manager.getTime()]);\n      });\n\n      this.mediaRecorder.start();\n    });\n  }\n\n  pauseRecording() {\n    this.mediaRecorder.pause();\n  }\n\n  resumeRecording() {\n    this.mediaRecorder.resume();\n  }\n\n  async endRecording() {\n    this.mediaRecorder.stop();\n    return this.promise;\n  }\n\n  finalizeRecording(chunks: Blob[]) {\n    return new Blob(chunks, {type: \"video/webm\"});\n  }\n\n  requestRecording(\n    constraints: MediaStreamConstraints = {audio: true, video: true},\n  ) {\n    // be idempotent\n    if (this.requested) return;\n\n    const request = async () => {\n      // Only need to do this once...\n      window.removeEventListener(\"click\", request);\n\n      try {\n        this.stream = await navigator.mediaDevices.getUserMedia(constraints);\n      } catch (e) {\n        // User said no or browser rejected request due to insecure context\n        console.log(\"no recording allowed\");\n      }\n    };\n\n    // Need user interaction to request media\n    window.addEventListener(\"click\", request);\n    this.requested = true;\n  }\n}\n\nexport function VideoSaveComponent(props: {data: Blob}) {\n  return (\n    <>\n      {props.data ? (\n        <a download=\"video.webm\" href={URL.createObjectURL(props.data)}>\n          Download Video\n        </a>\n      ) : (\n        \"Video not yet available\"\n      )}\n    </>\n  );\n}\n\nconst recorder = new VideoRecorder();\nexport const VideoRecording: RecorderPlugin<Blob, Blob, VideoRecorder> = {\n  enabled: () => {\n    if (typeof recorder.stream === \"undefined\") {\n      if (isClient) recorder.requestRecording();\n      return false;\n    }\n    return true;\n  },\n  icon,\n  key: \"video\",\n  name: \"Video\",\n  recorder,\n  saveComponent: VideoSaveComponent,\n  title: \"Record video\",\n};\n"
  },
  {
    "path": "packages/recording/src/types.ts",
    "content": "import type {Recorder} from \"./recorder\";\n\nexport interface RecordingPlugin<\n  T = unknown,\n  F = T[],\n  R extends Recorder<T, F> = Recorder<T, F>,\n> {\n  enabled?: () => boolean;\n\n  /** SVG icon for plugin. */\n  icon: JSX.Element;\n\n  /** Unique key for plugin. */\n  key: string;\n\n  /** Name for plugin. */\n  name: string;\n\n  /** Recorder component for plugin. */\n  recorder: R;\n\n  /** Save component. */\n  saveComponent: React.FC<{data: F}>;\n\n  /** Optional title. */\n  title?: string;\n}\n\nexport {RecordingPlugin as RecorderPlugin};\n"
  },
  {
    "path": "packages/recording/styl/style.styl",
    "content": "$blue = #1A69B5\n$red = #A52117\n\nuser-select(value)\n  user-select value\n  -webkit-user-select value\n\n@media (any-hover: none)\n  #lv-recording\n    display none\n\n#lv-recording\n  position relative\n\n#lv-recording-dialog\n  background-color #2A2A2A\n  border-radius 2px 2px 0 0\n  box-shadow 2px -2px 2px 2px rgba(0, 0, 0, 0.3)\n  box-sizing border-box\n  color #FFF\n  font-family sans-serif\n  line-height 1\n  \n  position absolute\n  bottom calc(var(--lv-controls-height) - 2px)\n  right 0\n  z-index 3\n  \n  max-height 20rem\n  overflow-y auto\n  padding .5em\n  width 23rem\n  \n  > h3\n    color $blue\n    margin .5em 0 .2em\n\n#lv-recording-configuration\n  border-spacing 0 1em\n  width 100%\n  \n  > tbody > tr\n    > th, > td\n      vertical-align top\n    \n    > th\n      text-align right\n      \n    td\n      padding-left 1em\n    \n    > th[colspan=\"2\"]\n      color $blue\n      text-align center\n\n    > th:not([colspan=\"2\"])\n      font-size 1em\n      font-weight normal\n      padding-right .2em\n      text-align right\n\n/* configuration */\n.recorder-plugin\n  display inline-block\n  font-family sans-serif\n  font-size .8em\n  margin 0 .25em\n  text-align center\n  \n.recorder-plugin-icon\n  background-color #222\n  border-radius 5px\n  cursor pointer\n  display block\n  margin 0 auto\n  \n  &.active\n    background-color #F00\n    \n  text\n    user-select none\n\n/* saved recordings */\n.recordings\n  font-size 1.5em\n  list-style-position inside\n\n.recording-results\n  border-collapse collapse\n  font-size 0.6em\n  width 100%\n\n  > caption\n    padding-left 54px\n    text-align left\n    user-select text\n  \n  > tbody > tr\n    background-color #333\n    \n    > th\n      padding 6px\n      width 36px\n    \n    > td\n      padding 6px\n    \n  textarea\n    width 100%\n\n  :link\n    background-color $blue\n    box-sizing border-box\n    color #FFF !important\n    display block\n    padding .5em\n    text-align center\n    text-decoration none\n    width 100%\n\n.shortcut\n  font-family monospace\n  font-size 1em\n  width 18ch\n"
  },
  {
    "path": "packages/recording/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"removeComments\": false,\n    \"rootDir\": \"./src\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/renderer/README.md",
    "content": "# @liqvid/renderer\n\nThis 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:\n\n[`liqvid audio convert`](https://liqvidjs.org/docs/cli/audio#convert)\n\n[`liqvid audio join`](https://liqvidjs.org/docs/cli/audio#join)\n\n[`liqvid render`](https://liqvidjs.org/docs/cli/render)\n\n[`liqvid thumbs`](https://liqvidjs.org/docs/cli/thumbs)\n"
  },
  {
    "path": "packages/renderer/package.json",
    "content": "{\n  \"name\": \"@liqvid/renderer\",\n  \"version\": \"1.1.0\",\n  \"description\": \"Audio utilities, static video rendering, and thumbnail generation for Liqvid\",\n  \"files\": [\"dist/*\"],\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.mjs\"\n    },\n    \"./convert\": {\n      \"import\": \"./dist/tasks/convert.mjs\"\n    },\n    \"./join\": {\n      \"import\": \"./dist/tasks/join.mjs\"\n    },\n    \"./solidify\": {\n      \"import\": \"./dist/tasks/solidify.mjs\"\n    },\n    \"./thumbs\": {\n      \"import\": \"./dist/tasks/thumbs.mjs\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"index\": [\"./dist/index.d.mts\"],\n      \"*\": [\"./dist/tasks/*.d.mts\"]\n    }\n  },\n  \"sideEffects\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/renderer#readme\",\n  \"devDependencies\": {\n    \"@types/cli-progress\": \"^3.9.2\",\n    \"@types/puppeteer-core\": \"^5.4.0\"\n  },\n  \"dependencies\": {\n    \"@liqvid/utils\": \"workspace:*\",\n    \"cli-progress\": \"^3.10.0\",\n    \"execa\": \"^6.1.0\",\n    \"jimp\": \"^0.16.1\",\n    \"puppeteer-core\": \"^13.5.2\",\n    \"puppeteer-mass-screenshots\": \"^1.0.15\",\n    \"puppeteer-video-recorder\": \"^1.0.5\",\n    \"yargs-parser\": \"^21.0.1\"\n  }\n}\n"
  },
  {
    "path": "packages/renderer/src/index.mts",
    "content": "export {convert} from \"./tasks/convert.mjs\";\nexport {join} from \"./tasks/join.mjs\";\nexport {solidify} from \"./tasks/solidify.mjs\";\nexport {thumbs} from \"./tasks/thumbs.mjs\";\n"
  },
  {
    "path": "packages/renderer/src/tasks/convert.mts",
    "content": "import {formatTime, parseTime} from \"@liqvid/utils/time\";\nimport cliProgress from \"cli-progress\";\nimport {execa} from \"execa\";\nimport fs, {promises as fsp} from \"fs\";\nimport path from \"path\";\nimport {ffmpegExists} from \"../utils/binaries.mjs\";\n\n/** Repair and convert audio files */\nexport async function convert({\n  filename,\n}: {\n  filename?: string;\n}) {\n  // check that ffmpeg exists\n  if (!(await ffmpegExists())) {\n    console.error(\n      \"ffmpeg must be installed and in your PATH. Download it from\",\n    );\n    console.error(\"https://ffmpeg.org/download.html\");\n    process.exit(1);\n  }\n\n  // check that audio file exists\n  if (!fs.existsSync(filename)) {\n    console.error(`Audio file ${filename} not found`);\n    process.exit(1);\n  }\n\n  /* actual conversion */\n  const basename = path.basename(filename, \".webm\");\n  const dirname = path.dirname(filename);\n\n  // fix browser recording\n  console.log(\"(1/2) Fixing webm...\");\n  await fixWebm(filename, path.join(dirname, basename + \"-fixed.webm\"));\n\n  // make available in mp4\n  console.log(\"(2/2) Converting to mp4...\");\n  await convertMp4(filename, path.join(dirname, basename + \".mp4\"));\n\n  console.log(\"Done!\");\n}\n\n/** Reencode webm */\nasync function fixWebm(src: string, tmp: string) {\n  const duration = await getDuration(src);\n\n  // progress bar\n  const bar = new cliProgress.SingleBar(\n    {\n      autopadding: true,\n      clearOnComplete: true,\n      etaBuffer: 50,\n      format: \"{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}\",\n      formatValue: (v, options, type) => {\n        if (type === \"value\" || type === \"total\") {\n          return formatTime(v);\n        }\n        return cliProgress.Format.ValueFormat(v, options, type);\n      },\n      hideCursor: true,\n    },\n    cliProgress.Presets.shades_classic,\n  );\n\n  /* ffmpeg job */\n  const job = execa(\"ffmpeg\", [\"-y\", \"-i\", src, \"-strict\", \"-2\", tmp]);\n\n  // parse ffmpeg progress\n  job.stderr.on(\"data\", (msg: Buffer) => {\n    const $_ = msg.toString().match(/time=(\\d+:\\d+:\\d+.\\d+)/);\n    if ($_) {\n      bar.update(parseTime($_[1]));\n    }\n  });\n\n  bar.start(duration, 0);\n  await job;\n  bar.stop();\n\n  // rename file\n  await fsp.rename(tmp, src);\n}\n\n/** Make available as mp4 */\nasync function convertMp4(src: string, dest: string) {\n  const duration = await getDuration(src);\n\n  // progress bar\n  const bar = new cliProgress.SingleBar(\n    {\n      autopadding: true,\n      clearOnComplete: true,\n      etaBuffer: 50,\n      format: \"{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}\",\n      formatValue: (v, options, type) => {\n        if (type === \"value\" || type === \"total\") {\n          return formatTime(v);\n        }\n        return cliProgress.Format.ValueFormat(v, options, type);\n      },\n      hideCursor: true,\n    },\n    cliProgress.Presets.shades_classic,\n  );\n\n  /* ffmpeg job */\n  const job = execa(\"ffmpeg\", [\"-y\", \"-i\", src, dest]);\n\n  // parse ffmpeg progress\n  job.stderr.on(\"data\", (msg: Buffer) => {\n    const $_ = msg.toString().match(/time=(\\d+:\\d+:\\d+.\\d+)/);\n    if ($_) {\n      bar.update(parseTime($_[1]));\n    }\n  });\n\n  bar.start(duration, 0);\n  await job;\n  bar.stop();\n}\n\n/**\n * Get duration in milliseconds of audio file\n * @param filename Path to audio file\n * @returns Duration in milliseconds\n */\nasync function getDuration(filename: string) {\n  const res = await execa(\"ffprobe\", [\n    \"-i\",\n    filename,\n    \"-show_entries\",\n    \"format=duration\",\n    \"-v\",\n    \"quiet\",\n    \"-of\",\n    \"csv=p=0\",\n  ]);\n  return parseFloat(res.stdout) * 1000;\n}\n"
  },
  {
    "path": "packages/renderer/src/tasks/join.mts",
    "content": "import {execa} from \"execa\";\nimport {promises as fsp} from \"fs\";\nimport os from \"os\";\nimport path from \"path\";\n\n/**\n * Join multiple audio files into one.\n */\nexport async function join({\n  filenames,\n  output,\n}: {\n  /** Files to join. */\n  filenames: string[];\n\n  /** Destination file. If not specified, defaults to last file in filenames. */\n  output?: string;\n}) {\n  if (filenames.length === 0) {\n    console.error(\"Must provide at least one input file\");\n    process.exit();\n  }\n\n  if (!output) {\n    if (filenames.length === 1) {\n      console.error(\"Must provide at least one input file\");\n      process.exit();\n    }\n    output = filenames.pop();\n  }\n\n  // create join list\n  const tempDir = await fsp.mkdtemp(\n    path.join(os.tmpdir(), \"liqvid.audio.join\"),\n  );\n  const myList = path.join(tempDir, \"mylist.txt\");\n  await fsp.writeFile(\n    myList,\n    filenames.map((name) => `file '${name}'`).join(\"\\n\"),\n  );\n\n  // ffmpeg command\n  const ext = path.extname(output);\n\n  const opts = [\n    \"-f\",\n    \"concat\",\n    \"-safe\",\n    \"0\",\n    \"-i\",\n    myList,\n    \"-c\",\n    \"copy\",\n    // special args for webm\n    ...(ext === \".webm\" ? [\"-strict\", \"-2\"] : []),\n    output,\n  ];\n\n  const job = execa(\"ffmpeg\", opts);\n  await job;\n\n  // clean up\n  await fsp.rm(tempDir, {recursive: true});\n}\n"
  },
  {
    "path": "packages/renderer/src/tasks/solidify.mts",
    "content": "import cliProgress from \"cli-progress\";\nimport fs, {promises as fsp} from \"fs\";\nimport os from \"os\";\nimport path from \"path\";\n\nimport {ffmpegExists, getEnsureChrome} from \"../utils/binaries.mjs\";\nimport {captureRange} from \"../utils/capture.mjs\";\nimport {validateConcurrency} from \"../utils/concurrency.mjs\";\nimport {getPages} from \"../utils/connect.mjs\";\nimport {Pool} from \"../utils/pool.mjs\";\nimport {stitch} from \"../utils/stitch.mjs\";\nimport {formatTime, parseTime} from \"@liqvid/utils/time\";\n\nimport {ImageFormat} from \"../types\";\n\n/**\n  Render an interactive (\"liquid\") video as a static (\"solid\") video.\n*/\nexport async function solidify({\n  browserExecutable,\n  colorScheme = \"light\",\n  concurrency,\n  duration,\n  end,\n  height,\n  quality,\n  sequence,\n  url,\n  width,\n  ...o // passthrough parameters\n}: Omit<Parameters<typeof assembleVideo>[0], \"framesDir\" | \"padLen\"> & {\n  browserExecutable: string;\n  colorScheme: \"light\" | \"dark\";\n  concurrency: number;\n  duration: number;\n  end: number;\n  height: number;\n  quality: number;\n  sequence: boolean;\n  url: string;\n  width: number;\n}) {\n  let step = 1;\n  const total = sequence ? 2 : 3;\n\n  /* validation */\n  // make sure chrome exists, or download it\n  const executablePath = await getEnsureChrome(browserExecutable);\n\n  // check that ffmpeg exists\n  if (!sequence && !(await ffmpegExists())) {\n    console.error(\n      \"ffmpeg must be installed and in your PATH. Download it from\",\n    );\n    console.error(\"https://ffmpeg.org/download.html\");\n    process.exit(1);\n  }\n\n  // check that audio file exists\n  if (o.audioFile && !fs.existsSync(o.audioFile)) {\n    console.error(`Audio file ${o.audioFile} not found`);\n    process.exit(1);\n  }\n\n  // validate start/end time\n  if (end <= o.start) {\n    console.error(\"End time cannot be before start time\");\n    process.exit(1);\n  }\n\n  // bound concurrency\n  concurrency = validateConcurrency(concurrency);\n\n  // make sure output directory exists\n  if (sequence) {\n    await fsp.mkdir(o.output, {recursive: true});\n  }\n\n  /* calculate other values */\n  // pool of puppeteer instances\n  console.log(`(${step++}/${total}) Connecting to players...`);\n  const pages = await getPages({\n    colorScheme,\n    concurrency,\n    executablePath,\n    url,\n    height,\n    width,\n  });\n  for (const page of pages) {\n    (page as any).client = await page.target().createCDPSession();\n  }\n  const pool = new Pool(pages);\n\n  // get duration\n  const totalDuration = await pages[0].evaluate(() => {\n    return player.playback.duration;\n  });\n\n  if (o.start >= totalDuration) {\n    console.error(\"Start cannot be after video endtime\");\n    process.exit(1);\n  }\n\n  const realDuration = (() => {\n    if (typeof duration === \"number\") {\n      return Math.min(totalDuration - o.start, duration);\n    } else if (typeof end === \"number\") {\n      return Math.min(end - o.start, totalDuration);\n    }\n    return totalDuration - o.start;\n  })();\n\n  // frames dir\n  const framesDir = sequence\n    ? o.output\n    : await fsp.mkdtemp(path.join(os.tmpdir(), \"liqvid.render\"));\n\n  // calculate how many frames\n  const count = Math.ceil((o.fps * realDuration) / 1000);\n  const padLen = String(count - 1).length;\n\n  /* capture and assemble */\n  // capture frames\n  console.log(`(${step++}/${total}) Capturing frames...`);\n  await captureRange({\n    count,\n    filename: (i) =>\n      path.join(\n        framesDir,\n        String(i).padStart(padLen, \"0\") + `.${o.imageFormat}`,\n      ),\n    imageFormat: o.imageFormat,\n    pool,\n    quality,\n    time: (i) => o.start + (i * 1000) / o.fps,\n  });\n\n  // close chrome instances\n  for (const page of pages) {\n    page.close();\n  }\n\n  // stitch them\n  if (!sequence) {\n    console.log(`(${step++}/${total}) Assembling video...`);\n    await assembleVideo({\n      duration: realDuration,\n      framesDir,\n      padLen,\n      ...o,\n    });\n\n    // clean up tmp files\n    console.log(\"Cleaning up...\");\n    await fsp.rm(framesDir, {recursive: true});\n  }\n\n  // done\n  console.log(\"Done!\");\n}\n\n/**\nAssemble frames into a video.\n*/\nasync function assembleVideo({\n  padLen,\n  ...o // passthrough parameters\n}: Omit<Parameters<typeof stitch>[0], \"pattern\"> & {\n  imageFormat: ImageFormat;\n  padLen: number;\n}) {\n  // progress bar\n  const stitchingBar = new cliProgress.SingleBar(\n    {\n      autopadding: true,\n      clearOnComplete: true,\n      etaBuffer: 50,\n      format: \"{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}\",\n      formatValue: (v, options, type) => {\n        if (type === \"value\" || type === \"total\") {\n          return formatTime(v);\n        }\n        return cliProgress.Format.ValueFormat(v, options, type);\n      },\n      hideCursor: true,\n    },\n    cliProgress.Presets.shades_classic,\n  );\n\n  stitchingBar.start(o.duration, 0);\n\n  // ffmpeg stitch job\n  const job = stitch({\n    pattern: `%0${padLen}d.${o.imageFormat}`,\n    ...o,\n  });\n\n  // parse ffmpeg progress\n  job.stderr.on(\"data\", (msg: Buffer) => {\n    const $_ = msg.toString().match(/time=(\\d+:\\d+:\\d+.\\d+)/);\n    if ($_) {\n      stitchingBar.update(parseTime($_[1]));\n    }\n  });\n\n  await job;\n\n  stitchingBar.stop();\n}\n"
  },
  {
    "path": "packages/renderer/src/tasks/thumbs.mts",
    "content": "import cliProgress from \"cli-progress\";\nimport {promises as fsp} from \"fs\";\nimport jimp from \"jimp\";\nimport os from \"os\";\nimport path from \"path\";\nimport puppeteer from \"puppeteer-core\";\n\nimport {ImageFormat} from \"../types\";\n\nimport {getEnsureChrome} from \"../utils/binaries.mjs\";\nimport {captureRange} from \"../utils/capture.mjs\";\nimport {validateConcurrency} from \"../utils/concurrency.mjs\";\nimport {getPages} from \"../utils/connect.mjs\";\nimport {Pool} from \"../utils/pool.mjs\";\n\n/**\nCreate thumbnail sheets for a Liqvid video.\n*/\nexport async function thumbs({\n  browserExecutable,\n  browserHeight,\n  browserWidth,\n  colorScheme = \"light\",\n  cols,\n  concurrency,\n  frequency,\n  height,\n  imageFormat,\n  output,\n  quality,\n  rows,\n  url,\n  width,\n}: {\n  browserExecutable: string;\n  browserHeight: number;\n  browserWidth: number;\n  colorScheme: \"light\" | \"dark\";\n  cols: number;\n  concurrency: number;\n  frequency: number;\n  height: number;\n  imageFormat: ImageFormat;\n  output: string;\n  quality: number;\n  rows: number;\n  url: string;\n  width: number;\n}) {\n  let step = 1;\n  const total = 3;\n\n  // validation\n  const executablePath = await getEnsureChrome(browserExecutable);\n\n  if (path.extname(output) !== `.${imageFormat}`) {\n    console.error(\n      `Error: File pattern '${output}' does not match format '${imageFormat}'.`,\n    );\n    process.exit(1);\n  }\n\n  concurrency = validateConcurrency(concurrency);\n\n  // browserHeight / browserWidth default to height/width\n  browserHeight ??= height;\n  browserWidth ??= width;\n\n  // make directories\n  const [tmpDir] = await Promise.all([\n    fsp.mkdtemp(path.join(os.tmpdir(), \"liqvid.thumbs\")),\n    fsp.mkdir(path.dirname(output), {recursive: true}),\n  ]);\n\n  // pool of puppeteer instances\n  console.log(`(${step++}/${total}) Connecting to players...`);\n  const pages = await getPages({\n    colorScheme,\n    concurrency,\n    url,\n    executablePath,\n    height: browserHeight,\n    width: browserWidth,\n  });\n  const pool = new Pool(pages);\n  for (const page of pages) {\n    (page as any).client = await page.target().createCDPSession();\n  }\n\n  // calculate how many thumbs\n  const duration = await pages[0].evaluate(() => {\n    return player.playback.duration;\n  });\n\n  const numThumbs = Math.ceil(duration / frequency / 1000);\n\n  // grab thumbs and assemble them\n  console.log(`(${step++}/${total}) Capturing thumbs...`);\n  await captureRange({\n    count: numThumbs,\n    filename: (i) => path.join(tmpDir, `${i}.${imageFormat}`),\n    imageFormat,\n    pool,\n    time: (i) => i * frequency * 1000,\n  });\n\n  // close chrome instances\n  pages[0].browser().close();\n\n  console.log(`(${step++}/${total}) Assembling sheets...`);\n  await assembleSheets({\n    cols,\n    height,\n    imageFormat,\n    numThumbs,\n    output,\n    pool,\n    quality,\n    rows,\n    tmpDir,\n    width,\n  });\n\n  // clean up tmp files\n  console.log(\"Cleaning up...\");\n  await fsp.rm(tmpDir, {recursive: true});\n\n  // done\n  console.log(\"Done!\");\n}\n\n/**\nAssemble thumb screenshots into sheets.\n*/\nasync function assembleSheets({\n  cols,\n  height,\n  imageFormat,\n  numThumbs,\n  output,\n  pool,\n  quality,\n  rows,\n  tmpDir,\n  width,\n}: {\n  cols: number;\n  height: number;\n  imageFormat: ImageFormat;\n  numThumbs: number;\n  output: string;\n  pool: Pool<puppeteer.Page>;\n  quality: number;\n  rows: number;\n  tmpDir: string;\n  width: number;\n}) {\n  const numSheets = Math.ceil(numThumbs / cols / rows);\n\n  // progress bar\n  const sheetsBar = new cliProgress.SingleBar(\n    {\n      autopadding: true,\n      clearOnComplete: true,\n      format: \"{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}\",\n      hideCursor: true,\n    },\n    cliProgress.Presets.shades_classic,\n  );\n\n  sheetsBar.start(numThumbs, 0);\n\n  await Promise.all(\n    new Array(numSheets).fill(null).map(async (_, sheetNum) => {\n      // get available puppeteer instance\n      const page = await pool.acquire();\n\n      const sheet = await new jimp(cols * width, rows * height);\n\n      // blit thumbs into here\n      await Promise.all(\n        new Array(cols * rows).fill(null).map(async (_, i) => {\n          const index = sheetNum * cols * rows + i;\n          if (index >= numThumbs) return;\n\n          const thumb = await jimp.read(\n            path.join(tmpDir, `${index}.${imageFormat}`),\n          );\n          if (imageFormat === \"jpeg\") {\n            thumb.quality(quality);\n          }\n          await thumb.resize(width, height);\n          await sheet.blit(\n            thumb,\n            (i % cols) * width,\n            Math.floor(i / rows) * height,\n          );\n          sheetsBar.increment();\n        }),\n      );\n\n      await sheet.writeAsync(output.replace(\"%s\", sheetNum.toString()));\n\n      // release puppeteer instance\n      pool.release(page);\n    }),\n  );\n  sheetsBar.stop();\n}\n"
  },
  {
    "path": "packages/renderer/src/types.ts",
    "content": "export type ImageFormat = \"jpeg\" | \"png\";\n\n// hilarious!!\ndeclare global {\n  const Liqvid: {\n    Utils: {\n      misc: {\n        waitFor(callback: () => boolean, interval?: number): Promise<void>;\n      };\n    };\n  };\n  var player: {\n    canPlay: Promise<void>;\n    playback: {\n      duration: number;\n      play(): Promise<void>;\n      seek: (t: number) => void;\n    };\n  };\n}\n\n// declare module \"puppeteer-core\" {\n//   export interface Page {\n//     screenshot(): Promise<Buffer>;\n//   }\n// }\n"
  },
  {
    "path": "packages/renderer/src/utils/binaries.mts",
    "content": "import {execa} from \"execa\";\nimport fs from \"fs\";\nimport os from \"os\";\n// sillyness\nimport Puppeteer from \"puppeteer-core\";\n\nconst puppeteer = Puppeteer as unknown as Puppeteer.PuppeteerNode;\n\nexport async function ffmpegExists() {\n  const locate = os.platform() === \"win32\" ? \"where\" : \"which\";\n  try {\n    await execa(locate, [\"ffmpeg\"]);\n    return true;\n  } catch (e) {\n    return false;\n  }\n}\n\n/**\nEnsure that a Chrome/ium executable exists on the machine, and return the path to it.\n*/\nexport async function getEnsureChrome(userChrome: string) {\n  // user-supplied path\n  if (userChrome) {\n    if (!fs.existsSync(userChrome)) {\n      console.warn(`Could not find browser executable at ${userChrome}`);\n    } else {\n      return userChrome;\n    }\n  }\n\n  // typical install\n  const systemChrome = await findChromeByPlatform();\n  if (systemChrome) return systemChrome;\n\n  // puppeteer preinstalled\n  const preinstalledChrome = puppeteer.executablePath();\n  if (fs.existsSync(preinstalledChrome)) return preinstalledChrome;\n\n  // puppeteer install\n  console.log(\n    \"No Chrome installation found. Downloading one from the internet...\",\n  );\n  const browserFetcher = puppeteer.createBrowserFetcher({});\n  const revisionInfo = await browserFetcher.download(\n    puppeteer._preferredRevision,\n  );\n  return revisionInfo.executablePath;\n}\n\n/**\nLook for Chrome/ium in standard locations across platforms.\n*/\nasync function findChromeByPlatform() {\n  switch (process.platform) {\n    case \"win32\":\n      return [\n        \"C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe\",\n        \"C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe\",\n      ].find((location) => fs.existsSync(location));\n    case \"darwin\":\n      return [\n        \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\",\n      ].find((location) => fs.existsSync(location));\n    default:\n      try {\n        const {stdout} = await execa(\"which\", [\n          \"google-chrome\",\n          \"chromium\",\n          \"chromium-browser\",\n        ]);\n        return stdout.split(\"\\n\")[0];\n      } catch (e) {\n        const {stdout} = e;\n        return stdout.split(\"\\n\").filter(Boolean)[0];\n      }\n  }\n}\n"
  },
  {
    "path": "packages/renderer/src/utils/capture.mts",
    "content": "import cliProgress from \"cli-progress\";\nimport type puppeteer from \"puppeteer-core\";\n\nimport type {Pool} from \"./pool.mjs\";\nimport {ImageFormat} from \"../types\";\n\nimport {promises as fsp} from \"fs\";\n\nexport async function capture({\n  page,\n  path,\n  quality,\n  time,\n  type,\n}: {\n  page: puppeteer.Page;\n  path: string;\n  quality?: number | undefined;\n  time: number;\n  type: ImageFormat;\n}) {\n  await page.evaluate((time) => {\n    player.playback.seek(time);\n  }, time);\n\n  const client = (page as any).client as puppeteer.CDPSession;\n  const options = {\n    format: type,\n    quality: type === \"jpeg\" ? quality : undefined,\n  };\n\n  const {data} = await client.send(\"Page.captureScreenshot\", options);\n  const base64Data = data.replace(/^data:image\\/png;base64,/, \"\");\n\n  return fsp.writeFile(path, base64Data, \"base64\");\n\n  return page.screenshot({\n    omitBackground: type === \"png\",\n    path,\n    // puppeteer will throw error if quality is passed for png\n    quality: type === \"jpeg\" ? quality : undefined,\n    type,\n  });\n}\n\n/**\nCapture a range of frames.\n*/\nexport async function captureRange({\n  count,\n  filename,\n  imageFormat,\n  pool,\n  quality,\n  time,\n}: {\n  count: number;\n  filename: (i: number) => string;\n  imageFormat: ImageFormat;\n  pool: Pool<puppeteer.Page>;\n  quality?: number | undefined;\n  time: (i: number) => number;\n}) {\n  // progress bar\n  const captureBar = new cliProgress.SingleBar(\n    {\n      autopadding: true,\n      clearOnComplete: true,\n      format: \"{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}\",\n      hideCursor: true,\n    },\n    cliProgress.Presets.shades_classic,\n  );\n  captureBar.start(count, 0);\n\n  // grab the thumbs\n  await Promise.all(\n    new Array(count).fill(null).map(async (_, i) => {\n      // get available puppeteer instance\n      const page = await pool.acquire();\n\n      // capture frame\n      await capture({\n        page,\n        time: time(i),\n        type: imageFormat,\n        path: filename(i),\n        quality,\n      });\n      captureBar.increment();\n\n      // release puppeteer instance\n      pool.release(page);\n    }),\n  );\n\n  captureBar.stop();\n}\n"
  },
  {
    "path": "packages/renderer/src/utils/concurrency.mts",
    "content": "import os from \"os\";\n\n/**\nRestrict concurrency to allowable values and avoid warnings.\n*/\nexport function validateConcurrency(concurrency: number) {\n  // constrain\n  concurrency = Math.max(1, Math.min(os.cpus().length, concurrency));\n\n  // force integer values\n  concurrency = Math.floor(concurrency);\n\n  // avoid EventEmitter warnings\n  if (concurrency > 10) {\n    process.setMaxListeners(0);\n  }\n\n  return concurrency;\n}\n"
  },
  {
    "path": "packages/renderer/src/utils/connect.mts",
    "content": "import cliProgress from \"cli-progress\";\nimport puppeteer from \"puppeteer-core\";\n\n/**\n  Connect to a page running Liqvid.\n*/\nexport async function connect({\n  browser,\n  colorScheme = \"light\",\n  height,\n  url,\n  width,\n}: {\n  browser: puppeteer.Browser;\n  colorScheme?: \"light\" | \"dark\";\n  height: number;\n  url: string;\n  width: number;\n}) {\n  // init page\n  const page = await browser.newPage();\n  page.setViewport({height, width});\n  page.on(\"error\", console.error);\n  page.on(\"pageerror\", console.error);\n\n  await page.goto(url, {timeout: 0});\n\n  await page.waitForSelector(\".rp-controls, .lv-controls\");\n\n  // hide controls\n  await page.evaluate(() => {\n    (document.querySelector(\".rp-controls\") as HTMLDivElement).style.display =\n      \"none\";\n    document.body.style.background = \"transparent\";\n  });\n\n  // set color scheme\n  await page.emulateMediaFeatures([\n    {\n      name: \"prefers-color-scheme\",\n      value: colorScheme,\n    },\n  ]);\n\n  // set player as global variable\n  // HA HA HA THIS IS HORRIBLE\n  await page.evaluate(async () => {\n    const searchKeys = [\"child\", \"stateNode\", \"current\"];\n\n    function searchTree(obj: any, depth = 0): unknown {\n      if (depth > 5) return;\n      for (const key of searchKeys) {\n        if (!obj[key]) continue;\n\n        if (\"playback\" in obj[key]) {\n          return obj[key];\n        } else if (typeof obj[key] === \"object\") {\n          const result = searchTree(obj[key], depth + 1);\n          if (result) return result;\n        }\n      }\n    }\n\n    const root = document.querySelector(\".ractive-player\").parentNode;\n    const key = Object.keys(root).find((key) =>\n      key.startsWith(\"__reactContainer\"),\n    );\n\n    await Liqvid.Utils.misc.waitFor(\n      () =>\n        ((window as any).player = searchTree(\n          root[key as keyof typeof root],\n        ) as boolean),\n    );\n  });\n\n  return page;\n}\n\n/**\nConnect to players.\n*/\nexport async function getPages({\n  colorScheme = \"light\",\n  concurrency,\n  executablePath,\n  height,\n  url,\n  width,\n}: {\n  colorScheme: \"light\" | \"dark\";\n  concurrency: number;\n  executablePath: string;\n  height: number;\n  url: string;\n  width: number;\n}) {\n  // progress bar\n  const playerBar = new cliProgress.SingleBar(\n    {\n      autopadding: true,\n      clearOnComplete: true,\n      etaBuffer: 1,\n      format: \"{bar} {percentage}% | ETA: {eta_formatted} | {value}/{total}\",\n      hideCursor: true,\n    },\n    cliProgress.Presets.shades_classic,\n  );\n  playerBar.start(concurrency, 0);\n\n  // get local browser\n  const browser = await puppeteer.launch({\n    args: [process.platform === \"linux\" ? \"--single-process\" : null].filter(\n      Boolean,\n    ),\n    executablePath,\n    ignoreHTTPSErrors: true,\n    product: \"chrome\",\n    timeout: 0,\n  });\n\n  // array of Page objects\n  const pages = await Promise.all(\n    new Array(concurrency).fill(null).map(async () => {\n      const page = await connect({\n        browser,\n        colorScheme,\n        height,\n        width,\n        url,\n      });\n\n      playerBar.increment();\n\n      return page;\n    }),\n  );\n  playerBar.stop();\n\n  return pages;\n}\n"
  },
  {
    "path": "packages/renderer/src/utils/pool.mts",
    "content": "export class Pool<T> {\n  private instances: T[];\n  private queue: ((free: T) => void)[];\n\n  constructor(instances: T[]) {\n    this.instances = instances;\n    this.queue = [];\n  }\n\n  acquire() {\n    const instance = this.instances.shift();\n    if (undefined !== instance) {\n      return Promise.resolve(instance);\n    }\n    return new Promise<T>((resolve) => {\n      this.queue.push((free: T) => resolve(free));\n    });\n  }\n\n  release(instance: T) {\n    const next = this.queue.shift();\n    if (undefined === next) {\n      this.instances.push(instance);\n    } else {\n      next(instance);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/renderer/src/utils/stitch.mts",
    "content": "import {execa} from \"execa\";\nimport path from \"path\";\nimport parser from \"yargs-parser\";\n\nimport {formatTimeMs} from \"@liqvid/utils/time\";\n\n/**\n  Stitch frames together into a video.\n*/\nexport function stitch({\n  audioArgs,\n  audioFile,\n  duration,\n  fps,\n  framesDir,\n  pattern,\n  output,\n  pixelFormat,\n  start,\n  videoArgs,\n}: {\n  audioArgs: string;\n  audioFile: string | undefined;\n  duration: number;\n  fps: number;\n  framesDir: string;\n  pattern: string;\n  output: string;\n  pixelFormat: string;\n  start?: number;\n  videoArgs: string;\n}) {\n  /* images */\n  const args = [\n    // framerate\n    \"-framerate\",\n    String(fps),\n\n    // frames\n    \"-i\",\n    path.join(framesDir, pattern),\n  ];\n\n  /* audio */\n  if (audioFile) {\n    args.push(\n      // start time\n      \"-ss\",\n      formatTimeMs(start),\n\n      // duration\n      \"-t\",\n      formatTimeMs(duration),\n\n      // audio args\n      ...splitArgs(audioArgs),\n\n      // audio file\n      \"-i\",\n      audioFile,\n    );\n  }\n\n  /* video */\n  args.push(\n    // pixel format\n    \"-pix_fmt\",\n    pixelFormat,\n\n    // force overwrite\n    \"-y\",\n\n    // video args\n    ...splitArgs(videoArgs),\n\n    output,\n  );\n  return execa(\"ffmpeg\", args.filter(Boolean));\n}\n\n// fuck\nfunction splitArgs(combined: string) {\n  if (!combined) return [];\n\n  const parsed = parser(combined, {\n    configuration: {\n      \"short-option-groups\": false,\n    },\n  });\n  return Object.keys(parsed).reduce((opts, key) => {\n    if (key === \"_\") return opts;\n    if (typeof parsed[key] === \"boolean\") return opts.concat([`-${key}`]);\n    return opts.concat([`-${key}`, parsed[key]]);\n  }, []);\n}\n"
  },
  {
    "path": "packages/renderer/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"module\": \"esnext\",\n    \"target\": \"esnext\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/server/README.md",
    "content": "# @liqvid/server\n\nThis package provides a development server for [Liqvid](https://liqvidjs.org). It is used internally by [@liqvid/cli](../cli).\n\n```bash\nliqvid serve\n```\n\nSee https://liqvidjs.org/docs/cli/serve for full documentation.\n"
  },
  {
    "path": "packages/server/package.json",
    "content": "{\n  \"name\": \"@liqvid/server\",\n  \"version\": \"1.0.2\",\n  \"description\": \"Development server for Liqvid\",\n  \"main\": \"./dist/index.js\",\n  \"typings\": \"./dist/index.d.ts\",\n  \"files\": [\"dist/*\"],\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"private\": false,\n  \"dependencies\": {\n    \"@liqvid/magic\": \"workspace:^\",\n    \"@types/express\": \"^4.17.13\",\n    \"@types/node\": \"^16.11.13\",\n    \"body-parser\": \"^1.19.1\",\n    \"compression\": \"^1.7.4\",\n    \"cookie-parser\": \"^1.4.6\",\n    \"express\": \"^4.17.1\",\n    \"livereload\": \"^0.9.3\",\n    \"typescript\": \"^4.5.4\",\n    \"webpack\": \"^5.65.0\"\n  },\n  \"devDependencies\": {\n    \"@types/compression\": \"^1.7.2\",\n    \"@types/cookie-parser\": \"^1.4.2\",\n    \"@types/livereload\": \"^0.9.1\"\n  }\n}\n"
  },
  {
    "path": "packages/server/src/index.ts",
    "content": "import {\n  ScriptData,\n  scripts as defaultScripts,\n  StyleData,\n  styles as defaultStyles,\n  transform,\n} from \"@liqvid/magic\";\nimport bodyParser from \"body-parser\";\nimport {exec} from \"child_process\";\nimport compression from \"compression\";\nimport cookieParser from \"cookie-parser\";\nimport express from \"express\";\nimport {promises as fsp} from \"fs\";\nimport livereload from \"livereload\";\nimport * as path from \"path\";\nimport webpack from \"webpack\";\nimport type {AddressInfo} from \"ws\";\n\n/**\n * Create Express app to run Liqvid development server.\n */\nexport function createServer(config: {\n  /**\n   * Build directory.\n   */\n  build?: string;\n\n  /**\n   * Port to run LiveReload on.\n   */\n  livereloadPort?: number;\n\n  /**\n   * Port to run the server on.\n   */\n  port?: number;\n\n  /**\n   * Static directory.\n   */\n  static?: string;\n\n  scripts?: Record<string, ScriptData>;\n\n  styles?: Record<string, StyleData>;\n}) {\n  const app = express();\n\n  // standard stuff\n  app.use(compression());\n\n  /* body parsing? */\n  app.use(cookieParser(/*process.env.SECURE_KEY*/));\n  app.use(bodyParser.json({limit: \"50mb\"}));\n  app.use(\n    bodyParser.urlencoded({\n      extended: true,\n    }),\n  );\n\n  // vars\n  app.set(\"static\", config.static);\n\n  // livereload\n  const lr = createLivereload(config.livereloadPort, config.static);\n  const lrPort = (lr.server.address() as AddressInfo).port;\n  app.set(\"livereloadPort\", lrPort);\n\n  // magic\n  const scripts = Object.assign({}, defaultScripts, config.scripts ?? {}, {\n    livereload: {\n      development() {\n        return (\n          \"document.write(`<script src=\\\"${location.protocol}//${(location.host || 'localhost').split(':')[0]}:\" +\n          lrPort +\n          \"/livereload.js?snipver=1\\\"></` + 'script>');\"\n        );\n      },\n    },\n  });\n  const styles = Object.assign({}, defaultStyles, config.styles ?? {});\n\n  // routes\n  app.use(\"/\", htmlMagic(scripts, styles));\n  app.use(\"/\", express.static(config.static));\n  app.use(\"/dist\", express.static(config.build));\n\n  // support dynamic port via config.port = 0\n  const server = app.listen(config.port);\n  server.on(\"listening\", () => {\n    const {port} = server.address() as AddressInfo;\n    app.set(\"port\", port);\n\n    console.log(`View your video at http://localhost:${port}`);\n\n    runWebpack(port);\n  });\n  server.on(\"error\", (err) => {\n    console.error(err);\n    process.exit(1);\n  });\n\n  return app;\n}\n\nfunction htmlMagic(\n  scripts: Record<string, ScriptData>,\n  styles: Record<string, StyleData>,\n): express.RequestHandler {\n  return async (req, res, next) => {\n    let filename;\n    if (req.path.endsWith(\"/\")) {\n      filename = req.path + \"index.html\";\n    } else if (req.path.endsWith(\".html\")) {\n      filename = req.path;\n    } else {\n      return next();\n    }\n\n    // content files\n    try {\n      const file = await fsp.readFile(\n        path.join(req.app.get(\"static\"), filename),\n        \"utf8\",\n      );\n      res.send(transform(file, {mode: \"development\", scripts, styles}));\n    } catch (e) {\n      next();\n    }\n  };\n}\n\n/**\n * Run LiveReload server\n * @param port Port to run LiveReload on\n */\nfunction createLivereload(port: number, staticDir: string) {\n  /* livereload */\n  const lrHttpServer = livereload.createServer({\n    exts: [\"html\", \"css\", \"png\", \"gif\", \"jpg\", \"svg\"],\n    port,\n  });\n\n  lrHttpServer.watch(staticDir);\n\n  return lrHttpServer;\n}\n\n/**\n * Run webpack.\n */\nfunction runWebpack(port: number) {\n  const webpackConfig = require(path.join(process.cwd(), \"webpack.config.js\"));\n\n  const compiler = webpack(webpackConfig);\n\n  // watch\n  let firstRun = true;\n\n  compiler.watch({}, (err, stats) => {\n    console.info(\n      stats.toString({\n        colors: true,\n      }),\n    );\n\n    // open in browser\n    if (firstRun) {\n      firstRun = false;\n      exec(`xdg-open http://localhost:${port}`);\n    }\n  });\n}\n"
  },
  {
    "path": "packages/server/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"module\": \"commonjs\",\n    \"target\": \"esnext\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/ssr/README.md",
    "content": "# @liqvid/ssr\n\nThis package contains some utilities to support server-side rendering, as well as rendering different versions of a component in development vs production.\n"
  },
  {
    "path": "packages/ssr/package.json",
    "content": "{\n  \"name\": \"@liqvid/ssr\",\n  \"version\": \"0.0.2\",\n  \"description\": \"Server-side rendering helpers for Liqvid\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/esm/index.mjs\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"types\": \"./dist/types/index.d.ts\"\n    },\n    \"./react\": {\n      \"import\": \"./dist/esm/react.mjs\",\n      \"require\": \"./dist/cjs/react.cjs\",\n      \"types\": \"./dist/types/react.d.ts\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\n        \"./dist/types/*\"\n      ]\n    }\n  },\n  \"files\": [\n    \"dist/*\"\n  ],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:js && pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc --module esnext --outDir dist/esm; tsc --module commonjs --outDir dist/cjs; node ../../build.mjs\",\n    \"build:postclean\": \"find ./dist -name tsconfig.tsbuildinfo -delete\",\n    \"lint\": \"biome check --fix\",\n    \"test\": \"jest\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/ssr#readme\",\n  \"sideEffects\": false,\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"catalog:\",\n    \"@types/react\": \"catalog:\"\n  },\n  \"peerDependencies\": {\n    \"@types/react\": \"catalog:peer\",\n    \"react\": \"catalog:peer\"\n  },\n  \"peerDependenciesMeta\": {\n    \"react\": {\n      \"optional\": true\n    }\n  }\n}\n"
  },
  {
    "path": "packages/ssr/src/index.ts",
    "content": "export const isClient = typeof globalThis.window !== \"undefined\";\n"
  },
  {
    "path": "packages/ssr/src/react.ts",
    "content": "/** biome-ignore-all lint/suspicious/noExplicitAny: variance */\n/** biome-ignore-all lint/suspicious/noTsIgnore: needs to work in multiple environments */\nimport { lazy } from \"react\";\n\ntype ComponentLoader<T extends React.ComponentType<any>> = () => Promise<\n  T | { default: T }\n>;\n\n/**\n * Import a component that should only render in development mode.\n */\nexport function devComponent<T extends React.ComponentType<any>>(\n  load: ComponentLoader<T>,\n) {\n  return splitComponent(load, async () => () => null);\n}\n\n/**\n * Import a component that should only render in production mode.\n */\nexport function prodComponent<T extends React.ComponentType<any>>(\n  load: ComponentLoader<T>,\n) {\n  return splitComponent(async () => () => null, load);\n}\n\n/**\n * Import different versions of a component for development and production mode.\n */\nexport function splitComponent<\n  D extends React.ComponentType<any>,\n  P extends React.ComponentType<any>,\n>(loadDev: ComponentLoader<D>, loadProd: ComponentLoader<P>) {\n  return lazy<D | P>(async () => {\n    let result: D | P | { default: D | P } | null = null;\n\n    // Next\n    try {\n      // @ts-ignore\n      switch (process.env.NODE_ENV) {\n        case \"development\":\n          result = await loadDev();\n          break;\n        case \"production\": {\n          result = await loadProd();\n          break;\n        }\n      }\n    } catch (_e) {\n      // Vite\n\n      // @ts-ignore\n      if (import.meta.env.DEV) {\n        result = await loadDev();\n        // @ts-ignore\n      } else if (import.meta.env.PROD) {\n        result = await loadProd();\n      }\n    }\n\n    if (!result) throw new Error(\"unrecognized build environment\");\n\n    // return component\n    if (\"default\" in result) return result;\n    return {\n      default: result,\n    };\n  });\n}\n"
  },
  {
    "path": "packages/ssr/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"target\": \"esnext\"\n  },\n  \"extends\": \"../../tsconfig.json\",\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/utils/CHANGELOG.md",
    "content": "## 1.10.0 (Jan 28, 2025)\n\n- add `isClient`\n\n## 1.9.1 (April 14, 2024)\n\n- added `assertDefined()` and `assertType()`\n"
  },
  {
    "path": "packages/utils/README.md",
    "content": "# @liqvid/utils\n\nThis package provides various helper functions for [Liqvid](https://liqvidjs.org). See https://liqvidjs.org/docs/reference/Utils/animation for documentation.\n"
  },
  {
    "path": "packages/utils/jest.config.js",
    "content": "module.exports = {\n  preset: \"ts-jest\",\n  testEnvironment: \"jsdom\",\n  testPathIgnorePatterns: [\"dist\"],\n  coverageReporters: [\"json-summary\"],\n  transform: {},\n};\n"
  },
  {
    "path": "packages/utils/package.json",
    "content": "{\n  \"name\": \"@liqvid/utils\",\n  \"version\": \"1.10.0\",\n  \"description\": \"Utility functions for Liqvid\",\n  \"exports\": {\n    \"./*\": {\n      \"import\": \"./dist/esm/*.mjs\",\n      \"require\": \"./dist/cjs/*.cjs\",\n      \"types\": \"./dist/types/*.d.ts\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/types/*.d.ts\"]\n    }\n  },\n  \"files\": [\"dist/*\"],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean; pnpm build:js; pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc --outDir dist/esm --module esnext; tsc --outDir dist/cjs --module commonjs; node ../../build.mjs\",\n    \"build:postclean\": \"rm dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"eslint --ext ts,tsx --fix src && eslint --ext ts,tsx --fix tests\",\n    \"test\": \"eslint src --ext ts,tsx && jest --coverage\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/utils#readme\",\n  \"dependencies\": {\n    \"bezier-easing\": \"^2.1.0\"\n  },\n  \"sideEffects\": false\n}\n"
  },
  {
    "path": "packages/utils/src/animation.ts",
    "content": "import {default as BezierEasing} from \"bezier-easing\";\n\nimport {clamp, lerp} from \"./misc\";\nimport type {ReplayData} from \"./replay-data\";\n\ninterface AnimateOptions {\n  /**\n   * Start value for animation.\n   * @default 0\n   */\n  startValue?: number;\n\n  /**\n   * End value for animation.\n   * @default 1\n   */\n  endValue?: number;\n\n  /** Start time for animation. */\n  startTime: number;\n\n  /** Duration of animation. */\n  duration: number;\n\n  /** Easing function. Defaults to the identity function, i.e. linear easing. */\n  easing?: (x: number) => number;\n}\n\n/**\n * Returns a function that takes in a time and returns a numeric value.\n * The function will return `startValue` whenever t is less than `startTime`, and similarly\n * will return `endValue` whenever t is greater than `startTime + duration`.\n *\n * You can use any time unit (seconds, milliseconds, days, …), as long as you\n * are consistent: `startTime`, `duration`, and the time parameter `t` must all\n * use the same units.\n *\n * If an array is passed, the functions are combined.\n */\nexport function animate(\n  options: AnimateOptions | AnimateOptions[],\n): (t: number) => number {\n  if (options instanceof Array) {\n    options.sort((a, b) => a.startTime - b.startTime);\n    const fns = options.map(animate);\n\n    return (t: number): number => {\n      let i = 0;\n      for (; i < fns.length; ++i) {\n        if (options[i].startTime > t) {\n          if (i === 0) return options[0].startValue;\n\n          return fns[i - 1](t);\n        }\n      }\n      return fns[options.length - 1](t);\n    };\n  }\n\n  if (!(\"startValue\" in options)) options.startValue = 0;\n  if (!(\"endValue\" in options)) options.endValue = 1;\n  if (!(\"easing\" in options)) options.easing = (x: number) => x;\n\n  const {startValue, endValue, startTime, duration, easing} = options;\n\n  return (t: number) =>\n    lerp(startValue, endValue, easing(clamp(0, (t - startTime) / duration, 1)));\n}\n\n/** Cubic Bezier curve function */\nexport const bezier = BezierEasing;\n\n/** Parameters for common Bezier curves. */\nexport const easings = {\n  easeInSine: [0.47, 0, 0.745, 0.715],\n  easeOutSine: [0.39, 0.575, 0.565, 1],\n  easeInOutSine: [0.445, 0.05, 0.55, 0.95],\n  easeInQuad: [0.55, 0.085, 0.68, 0.53],\n  easeOutQuad: [0.25, 0.46, 0.45, 0.94],\n  easeInOutQuad: [0.455, 0.03, 0.515, 0.955],\n  easeInCubic: [0.55, 0.055, 0.675, 0.19],\n  easeOutCubic: [0.215, 0.61, 0.355, 1],\n  easeInOutCubic: [0.645, 0.045, 0.355, 1],\n  easeInQuart: [0.895, 0.03, 0.685, 0.22],\n  easeOutQuart: [0.165, 0.84, 0.44, 1],\n  easeInOutQuart: [0.77, 0, 0.175, 1],\n  easeInQuint: [0.755, 0.05, 0.855, 0.06],\n  easeOutQuint: [0.23, 1, 0.32, 1],\n  easeInOutQuint: [0.86, 0, 0.07, 1],\n  easeInExpo: [0.95, 0.05, 0.795, 0.035],\n  easeOutExpo: [0.19, 1, 0.22, 1],\n  easeInOutExpo: [1, 0, 0, 1],\n  easeInCirc: [0.6, 0.04, 0.98, 0.335],\n  easeOutCirc: [0.075, 0.82, 0.165, 1],\n  easeInOutCirc: [0.785, 0.135, 0.15, 0.86],\n  easeInBack: [0.6, -0.28, 0.735, 0.045],\n  easeOutBack: [0.175, 0.885, 0.32, 1.275],\n  easeInOutBack: [0.68, -0.55, 0.265, 1.55],\n} as const;\n\n/**\n * Returns a function that takes in a time (in milliseconds) and returns the \"active\" replay datum. Useful for writing replay plugins.\n */\nexport function replay<K>({\n  data,\n  start,\n  end,\n  active,\n  inactive,\n  compressed,\n  units = 1,\n}: {\n  /** Recording data to iterate through. */\n  data: ReplayData<K>;\n\n  /**\n   * Start time.\n   * @default 0\n   */\n  start?: number;\n\n  /** End time. If not specified, defaults to `start` + total duration of `data`. */\n  end?: number;\n\n  /**\n   * If true, times are interpreted as relative. Otherwise, they are interpreted as absolute times.\n   *\n   * In a future release, this will likely default to `true`.\n   * @default false\n   */\n  compressed?: boolean;\n\n  /** Callback receiving active value and index of active value. */\n  active: (current: K, index: number) => void;\n\n  /** Callback called when replay is inactive. Doesn't get called repeatedly. */\n  inactive: () => void;\n\n  /**\n   * Scaling factor to convert time units to milliseconds. This affects {@link start},\n   * {@link end}, and the parameter of the returned function. When {@link compressed}\n   * is true, durations in {@link ReplayData} are **always** assumed to be in milliseconds.\n   * When {@link compressed} is false, this option has no effect.\n   *\n   * For example, if you wanted to measure time in seconds, you would pass 1000.\n   * @default 1\n   * @since 1.8.0\n   */\n  units?: number;\n}): (t: number) => void {\n  if (typeof compressed === \"undefined\") compressed = false;\n  if (typeof start === \"undefined\") start = 0;\n\n  const times = data.map(compressed ? (d) => d[0] / units : (d) => d[0]);\n  if (compressed) {\n    for (let i = 1; i < times.length; ++i) {\n      times[i] += times[i - 1];\n    }\n  }\n\n  if (typeof end === \"undefined\") end = start + times[times.length - 1];\n\n  let lastTime = 0,\n    i = 0,\n    isActive = true;\n\n  function listener(t: number) {\n    // don't call inactive() repeatedly\n    if (t < start || t >= end) {\n      if (isActive) {\n        isActive = false;\n        return inactive();\n      }\n      return;\n    }\n    isActive = true;\n\n    if (t < lastTime) i = 0;\n    lastTime = t;\n\n    let maxI = Math.min(i, times.length - 1);\n\n    for (; i < times.length; i++) {\n      if (start + times[i] < t) maxI = i;\n      else break;\n    }\n\n    const [, current] = data[maxI];\n\n    active(current, maxI);\n  }\n\n  return listener;\n}\n"
  },
  {
    "path": "packages/utils/src/interaction.ts",
    "content": "/* https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener#matching_event_listeners_for_removal */\ndeclare global {\n  interface EventListenerOptions {\n    passive?: boolean;\n  }\n}\n\n/**\n  Whether any available input mechanism can hover over elements. This is used as a standin for desktop/mobile.\n*/\nexport const anyHover =\n  typeof window !== \"undefined\"\n    ? window.matchMedia?.(\"(any-hover: hover)\")?.matches\n    : undefined;\n\n/**\n * Helper for implementing drag functionality, abstracting over mouse vs touch events.\n * @returns An event listener which should be added to both `mousedown` and `touchstart` events.\n */\nexport function onDrag(\n  /** Callback for dragging (pointer is moved while down). */\n  move: (\n    /** The underlying `mousemove` or `touchmove` event */\n    e: MouseEvent | TouchEvent,\n    /** Information about the pointer location */\n    hit: {\n      /** Horizontal coordinate of pointer */\n      x: number;\n      /** Vertical coordinate of pointer */\n      y: number;\n      /** Horizontal displacement since last call */\n      dx: number;\n      /** Vertical displacement since last call */\n      dy: number;\n    },\n  ) => void,\n  /** Callback for when dragging begins (pointer is touched). */\n  down: (\n    /** The underlying `mousedown` or `touchstart` event */\n    e: MouseEvent | TouchEvent,\n    /** Information about the pointer location */\n    hit: {\n      /** Horizontal coordinate of pointer */\n      x: number;\n      /** Vertical coordinate of pointer */\n      y: number;\n    },\n    /** The upHandler used internally by this method */\n    upHandler: (e: MouseEvent | TouchEvent) => void,\n    /** The moveHandler used internally by this method */\n    moveHandler: (e: MouseEvent | TouchEvent) => void,\n  ) => void = () => {},\n  /** Callback for when dragging ends (pointer is lifted). */\n  up: (\n    /** The underlying `mouseup` or `touchcancel`/`touchend` event */\n    e: MouseEvent | TouchEvent,\n    /** Information about the pointer location */\n    hit: {\n      /** Horizontal coordinate of pointer */\n      x: number;\n      /** Vertical coordinate of pointer */\n      y: number;\n      /** Horizontal displacement since last call */\n      dx: number;\n      /** Vertical displacement since last call */\n      dy: number;\n    },\n  ) => void = () => {},\n) {\n  return (e: MouseEvent | TouchEvent) => {\n    /* click events */\n    if (e instanceof MouseEvent) {\n      if (e.button !== 0) return;\n\n      let lastX = e.clientX,\n        lastY = e.clientY;\n\n      // up\n      const upHandler = (e: MouseEvent) => {\n        const dx = e.clientX - lastX,\n          dy = e.clientY - lastY;\n\n        document.body.removeEventListener(\"mousemove\", moveHandler);\n        window.removeEventListener(\"mouseup\", upHandler);\n\n        return up(e, {x: e.clientX, y: e.clientY, dx, dy});\n      };\n\n      // move\n      const moveHandler = (e: MouseEvent) => {\n        const dx = e.clientX - lastX,\n          dy = e.clientY - lastY;\n\n        lastX = e.clientX;\n        lastY = e.clientY;\n\n        return move(e, {x: e.clientX, y: e.clientY, dx, dy});\n      };\n\n      document.body.addEventListener(\"mousemove\", moveHandler, {\n        passive: false,\n      });\n      window.addEventListener(\"mouseup\", upHandler, {passive: false});\n\n      return down(e, {x: lastX, y: lastY}, upHandler, moveHandler);\n    } else {\n      /* touch events */\n      e.preventDefault();\n      const touches = e.changedTouches;\n\n      const touchId = touches[0].identifier;\n\n      let lastX = touches[0].clientX,\n        lastY = touches[0].clientY;\n\n      // up\n      const upHandler = (e: TouchEvent) => {\n        e.preventDefault();\n\n        for (const touch of Array.from(e.changedTouches)) {\n          if (touch.identifier !== touchId) continue;\n\n          const dx = touch.clientX - lastX,\n            dy = touch.clientY - lastY;\n\n          window.removeEventListener(\"touchend\", upHandler, {\n            capture: false,\n            passive: false,\n          });\n          window.removeEventListener(\"touchcancel\", upHandler, {\n            capture: false,\n            passive: false,\n          });\n          window.removeEventListener(\"touchmove\", moveHandler, {\n            capture: false,\n            passive: false,\n          });\n\n          return up(e, {x: touch.clientX, y: touch.clientY, dx, dy});\n        }\n      };\n\n      // move\n      const moveHandler = (e: TouchEvent) => {\n        e.preventDefault();\n        for (const touch of Array.from(e.changedTouches)) {\n          if (touch.identifier !== touchId) continue;\n\n          const dx = touch.clientX - lastX,\n            dy = touch.clientY - lastY;\n\n          lastX = touch.clientX;\n          lastY = touch.clientY;\n\n          return move(e, {x: touch.clientX, y: touch.clientY, dx, dy});\n        }\n      };\n\n      window.addEventListener(\"touchend\", upHandler, {\n        capture: false,\n        passive: false,\n      });\n      window.addEventListener(\"touchcancel\", upHandler, {\n        capture: false,\n        passive: false,\n      });\n      window.addEventListener(\"touchmove\", moveHandler, {\n        capture: false,\n        passive: false,\n      });\n\n      return down(e, {x: lastX, y: lastY}, upHandler, moveHandler);\n    }\n  };\n}\n\n/**\n * Replacement for addEventListener(\"click\") which works better on mobile.\n * @param node Event target.\n * @param callback Event listener.\n * @returns A function to remove the event listener.\n */\nexport function onClick<T extends HTMLElement | SVGElement>(\n  node: T,\n  callback: (e: (MouseEvent | TouchEvent) & {currentTarget: T}) => void,\n): () => void {\n  if (anyHover) {\n    node.addEventListener(\"click\", callback);\n    return () => {\n      node.removeEventListener(\"click\", callback);\n    };\n  }\n\n  let touchId: number;\n\n  // touchstart handler\n  const touchStart = (e: TouchEvent): void => {\n    if (typeof touchId === \"number\") return;\n    touchId = e.changedTouches[0].identifier;\n  };\n\n  // touchend handler\n  const touchEnd = (e: TouchEvent): void => {\n    if (typeof touchId !== \"number\") return;\n    for (const touch of Array.from(e.changedTouches)) {\n      if (touch.identifier !== touchId) continue;\n\n      if (\n        node.contains(document.elementFromPoint(touch.clientX, touch.clientY))\n      ) {\n        callback(e as TouchEvent & {currentTarget: T});\n      }\n\n      touchId = undefined;\n    }\n  };\n\n  node.addEventListener(\"touchstart\", touchStart);\n  node.addEventListener(\"touchend\", touchEnd);\n\n  return () => {\n    node.removeEventListener(\"touchstart\", touchStart);\n    node.removeEventListener(\"touchend\", touchEnd);\n  };\n}\n"
  },
  {
    "path": "packages/utils/src/interactivity.ts",
    "content": "/* legacy alias for interaction, kept for compatibility */\nexport * from \"./interaction\";\nexport {onDrag as dragHelper} from \"./interaction\";\n"
  },
  {
    "path": "packages/utils/src/json.ts",
    "content": "const results: Record<string, unknown> = {};\n\n// eslint-disable-next-line @typescript-eslint/no-empty-interface\nexport interface GetJSONMap {}\n\n/**\n * Preload all JSON resources.\n */\nexport function loadAllJSON() {\n  return Promise.all(\n    (\n      Array.from(\n        document.querySelectorAll(\n          \"link[data-name][rel='preload'][type='application/json']\",\n        ),\n      ) as HTMLLinkElement[]\n    ).map((link) =>\n      fetch(link.href)\n        .then((res) => res.json())\n        .then((json) => (results[link.dataset.name] = json)),\n    ),\n  ).then();\n}\n\n/**\n * Load a JSON record asynchronously.\n */\nexport function loadJSON<K extends keyof GetJSONMap>(\n  key: K,\n): Promise<GetJSONMap[K]> {\n  return new Promise((resolve, reject) => {\n    // check for cached result\n    if (results[key]) {\n      return resolve(results[key] as GetJSONMap[K]);\n    }\n    const link = document.querySelector(\n      `link[data-name=\"${key}\"][rel='preload'][type='application/json']`,\n    ) as HTMLLinkElement;\n    if (!link) {\n      return reject(`JSON record \"${key}\" not found`);\n    }\n    return fetch(link.href)\n      .then((res) => res.json())\n      .then((data) => {\n        // cache result\n        results[key] = data;\n        resolve(data);\n      })\n      .catch(reject);\n  });\n}\n\n/**\n * Access a preloaded JSON record synchronously.\n */\nexport function getJSON<K extends keyof GetJSONMap>(key: K) {\n  if (!results[key]) {\n    throw new Error(`JSON record \"${key}\" not loaded`);\n  }\n  return results[key] as GetJSONMap[K];\n}\n"
  },
  {
    "path": "packages/utils/src/misc.ts",
    "content": "/** Equivalent to `(min <= val) && (val < max)`. */\nexport function between(min: number, val: number, max: number) {\n  return min <= val && val < max;\n}\n\n/**\n * Bind methods on an object.\n * @param o Object on which to bind methods\n * @param methods Method names to bind\n */\n// eslint-disable-next-line @typescript-eslint/ban-types\nexport function bind<T extends {[P in K]: Function}, K extends keyof T>(\n  o: T,\n  methods: K[],\n) {\n  // eslint-disable-next-line @typescript-eslint/ban-types\n  for (const method of methods) o[method] = (o[method] as Function).bind(o);\n}\n\n/**\n * Linear interpolation from a to b.\n */\nexport function lerp(a: number, b: number, t: number) {\n  return a + t * (b - a);\n}\n\n/**\n * Clamps a value between a lower and upper bound. Aliased as {@link constrain}.\n * @param min Lower bound\n * @param val Value to clamp\n * @param max Upper bound\n */\nexport function clamp(min: number, val: number, max: number) {\n  return Math.min(max, Math.max(min, val));\n}\n\n/**\n * Clamps a value between a lower and upper bound. Alias for {@link clamp}.\n * @param min Lower bound\n * @param val Value to clamp\n * @param max Upper bound\n */\nexport function constrain(min: number, val: number, max: number) {\n  return clamp(min, val, max);\n}\n\n/**\n  Returns [a, b). For backwards compatibility, returns [0, a) if passed a single argument.\n*/\nexport function range(a: number, b?: number): number[] {\n  if (b === void 0) {\n    return range(0, a);\n  }\n  return new Array(b - a).fill(null).map((_, i) => a + i);\n}\n\n/** Returns a Promise that resolves in `time` milliseconds. */\nexport function wait(time: number): Promise<void> {\n  return new Promise((resolve) => {\n    setTimeout(resolve, time);\n  });\n}\n\n/** Returns a Promise that resolves once `callback` returns true. */\nexport function waitFor(callback: () => boolean, interval = 10): Promise<void> {\n  return new Promise((resolve) => {\n    const checkCondition = () => {\n      if (callback()) {\n        resolve();\n      } else {\n        setTimeout(checkCondition, interval);\n      }\n    };\n\n    checkCondition();\n  });\n}\n"
  },
  {
    "path": "packages/utils/src/react.ts",
    "content": "import {\n  Children,\n  cloneElement,\n  createContext,\n  isValidElement,\n  useMemo,\n  useReducer,\n  useRef,\n} from \"react\";\nimport {anyHover, onDrag as htmlOnDrag} from \"./interaction\";\n\n/**\n  Helper for the https://github.com/facebook/react/issues/2043 workaround. Use to intercept refs and\n  attach events.\n*/\nexport const captureRef =\n  <T>(callback: (ref: T) => void, innerRef?: React.Ref<T>) =>\n  (ref: T) => {\n    if (ref !== null) {\n      callback(ref);\n    }\n\n    if (innerRef === null) {\n      return;\n    } else if (typeof innerRef === \"function\") {\n      innerRef(ref);\n    } else if (typeof innerRef === \"object\") {\n      (innerRef as React.MutableRefObject<T>).current = ref;\n    }\n  };\n\n/**\n * Create a context guaranteed to be unique. Useful in case multiple versions of package are accidentally loaded.\n * @param name Unique key for context.\n * @param defaultValue Initial value for context.\n * @returns React context which is guaranteed to be stable.\n */\nexport function createUniqueContext<T>(\n  key: string,\n  defaultValue: T = undefined,\n  displayName?: string,\n): React.Context<T> {\n  const symbol = Symbol.for(key);\n\n  if (!(symbol in globalThis)) {\n    const context = createContext<T>(defaultValue);\n    context.displayName = displayName;\n    (globalThis as unknown as {[symbol]: React.Context<T>})[symbol] = context;\n  }\n\n  return (globalThis as unknown as {[symbol]: React.Context<T>})[symbol];\n}\n\n/**\n * Combine multiple refs into one\n * @param args Refs to combine\n * @returns A ref which applies all the passed refs\n */\nexport function combineRefs<T>(...args: React.Ref<T>[]): (o: T) => void {\n  return (o: T) => {\n    for (const ref of args) {\n      if (typeof ref === \"function\") {\n        ref(o);\n      } else if (ref === null) {\n      } else if (typeof ref === \"object\" && ref.hasOwnProperty(\"current\")) {\n        (ref as React.MutableRefObject<T>).current = o;\n      }\n    }\n  };\n}\n\n/**\n * Drop-in replacement for onClick handlers which works better on mobile.\n * @param callback Event listener.\n * @returns Props to attach to event target.\n */\nexport function onClick<T extends HTMLElement | SVGElement>(\n  callback: (e: React.MouseEvent<T> | React.TouchEvent<T>) => void,\n):\n  | {\n      onTouchStart: React.TouchEventHandler<T>;\n      onTouchEnd: React.TouchEventHandler<T>;\n    }\n  | {onClick: typeof callback} {\n  if (anyHover) {\n    return {onClick: callback};\n  } else {\n    let touchId: number, target: EventTarget & T;\n\n    // touchstart handler\n    const onTouchStart: React.TouchEventHandler<T> = (e) => {\n      if (typeof touchId === \"number\") return;\n      target = e.currentTarget as T;\n      touchId = e.changedTouches[0].identifier;\n    };\n\n    // touchend handler\n    const onTouchEnd: React.TouchEventHandler<T> = (e) => {\n      if (typeof touchId !== \"number\") return;\n\n      for (const touch of Array.from(e.changedTouches)) {\n        if (touch.identifier !== touchId) continue;\n\n        if (\n          target.contains(\n            document.elementFromPoint(touch.clientX, touch.clientY),\n          )\n        ) {\n          callback(e);\n        }\n\n        touchId = undefined;\n        break;\n      }\n    };\n\n    return {onTouchStart, onTouchEnd};\n  }\n}\n\n/**\n * Helper for implementing drag functionality, abstracting over mouse vs touch events.\n * @returns An object of event handlers which should be added to a React element with {...}\n */\nexport function onDrag(\n  move: Parameters<typeof htmlOnDrag>[0],\n  down?: Parameters<typeof htmlOnDrag>[1],\n  up?: Parameters<typeof htmlOnDrag>[2],\n): {\n  \"data-affords\": \"click\";\n  onMouseDown: React.MouseEventHandler;\n  onTouchStart: React.TouchEventHandler;\n} {\n  const listener = htmlOnDrag(move, down, up);\n\n  return {\n    \"data-affords\": \"click\",\n    onMouseDown: (e) => listener(e.nativeEvent),\n    onTouchStart: (e) => listener(e.nativeEvent),\n  };\n}\n\n/**\n * Recursive version of {@link React.Children.map}\n * @param children Children to iterate over\n * @param fn Callback function\n * @returns Transformed nodes\n */\nexport function recursiveMap(\n  children: React.ReactNode,\n  fn: (child: React.ReactElement<unknown>) => React.ReactElement<unknown>,\n): React.ReactNode[] {\n  return Children.map(children, (child) => {\n    if (!isValidElement<unknown>(child)) {\n      return child;\n    }\n\n    if (\"children\" in child.props) {\n      child = cloneElement(child, {\n        // @ts-expect-error TODO this used to work\n        children: recursiveMap(child.props.children, fn),\n      });\n    }\n\n    return fn(child);\n  });\n}\n\n/**\n * Get a function to force the component to update\n * @returns A forceUpdate() function\n */\nexport function useForceUpdate(): () => void {\n  return useReducer((c: number) => c + 1, 0)[1];\n}\n\n/**\n * Get a promise and resolver\n * @param deps React dependency list\n * @returns [promise, resolve, reject]\n */\nexport function usePromise(\n  deps: React.DependencyList = [],\n): [Promise<void>, () => void, () => void] {\n  const resolve = useRef<() => void>();\n  const reject = useRef<() => void>();\n\n  const promise = useMemo(\n    () =>\n      new Promise<void>((res, rej) => {\n        resolve.current = res;\n        reject.current = rej;\n      }),\n    deps,\n  );\n\n  return [promise, resolve.current, reject.current];\n}\n"
  },
  {
    "path": "packages/utils/src/replay-data.ts",
    "content": "/**\n * Type representing recorded data\n */\nexport type ReplayData<K> = [number, K][];\n\n/**\n * Concatenate several ReplayData together, with delays.\n * @param args [ReplayData, delay] objects to join\n * @returns Concatenated replay data\n */\nexport function concat<T>(...args: [ReplayData<T>, number][]) {\n  const [head, ...tail] = args;\n  const ret: ReplayData<T> = [...head[0]];\n  let ptr = head[1] + length(head[0]);\n\n  for (const [data, start] of tail) {\n    const copy = data.slice();\n    copy[0][0] += start - ptr;\n    ret.push(...copy);\n    ptr += length(copy);\n  }\n  return ret;\n}\n\n/**\n * Get the total duration of replay data.\n * @param data ReplayData item\n * @returns Duration of replay data\n */\nexport function length<T>(data: ReplayData<T>) {\n  return data.map((_) => _[0]).reduce((a, b) => a + b, 0);\n}\n"
  },
  {
    "path": "packages/utils/src/ssr.ts",
    "content": "export const isClient = typeof globalThis.window !== \"undefined\";\n"
  },
  {
    "path": "packages/utils/src/svg.ts",
    "content": "/**\n * Convert screen coordinates to SVG coordinates.\n * @param elt SVG Element\n * @param x Screen x coordinate\n * @param y Screen y coordinate\n * @returns [x, y] in SVG coordinates\n */\nexport function screenToSVG(\n  elt: SVGElement,\n  x: number,\n  y: number,\n): [number, number] {\n  let graphicsElt = elt;\n\n  while (!(graphicsElt instanceof SVGGraphicsElement))\n    graphicsElt = graphicsElt.parentNode as SVGElement;\n\n  const svgElt = elt instanceof SVGSVGElement ? elt : elt.ownerSVGElement;\n\n  const transform = graphicsElt.getScreenCTM().inverse();\n  let pt = svgElt.createSVGPoint();\n  (pt.x = x), (pt.y = y);\n\n  pt = pt.matrixTransform(transform);\n  return [pt.x, pt.y];\n  // const rect = svg.getBoundingClientRect(),\n  //       viewBox = svg.viewBox.baseVal,\n  //       aspectX = rect.width / viewBox.width,\n  //       aspectY = rect.height / viewBox.height,\n  //       svgX = (x - rect.left) / aspectX + viewBox.x,\n  //       svgY = (y - rect.top) / aspectY + viewBox.y;\n\n  // return [svgX, svgY];\n}\n\n/**\n * Convert screen vector coordinates to SVG vector coordinates.\n * @param svg SVG element\n * @param dx Relative screen x coordinate\n * @param dy Relative screen y coordinate\n * @returns [dx, dy] in SVG coordinates\n */\nexport function screenToSVGVector(\n  svg: SVGSVGElement,\n  dx: number,\n  dy: number,\n): [number, number] {\n  const rect = svg.getBoundingClientRect(),\n    viewBox = svg.viewBox.baseVal,\n    aspectX = rect.width / viewBox.width,\n    aspectY = rect.height / viewBox.height,\n    svgDx = dx / aspectX,\n    svgDy = dy / aspectY;\n\n  return [svgDx, svgDy];\n}\n"
  },
  {
    "path": "packages/utils/src/time.ts",
    "content": "/* time constants */\nconst SECONDS = 1000;\nconst MINUTES = 60 * SECONDS;\nconst HOURS = 60 * MINUTES;\nconst DAYS = 24 * HOURS;\n\n// nice minus sign\nconst MINUS_SIGN = \"\\u2212\";\n\n/**\n * Regular expression used to match times\n */\nexport const timeRegexp = new RegExp(\n  \"^\" + \"(?:(\\\\d+):)?\".repeat(3) + \"(\\\\d+)(?:\\\\.(\\\\d+))?$\",\n);\n\n/**\n * Parse a time string like \"3:43\" into milliseconds\n * @param str String to parse\n * @returns Time in milliseconds\n */\nexport function parseTime(str: string): number {\n  if (str[0] === MINUS_SIGN || str[0] === \"-\") {\n    return -parseTime(str.slice(1));\n  }\n\n  // d, h, m, s\n  const parts = str.split(\":\").map((x) => parseInt(x, 10));\n  while (parts.length < 4) {\n    parts.unshift(0);\n  }\n\n  // ms\n  const $_ = str.match(/\\.(\\d{0,3})/);\n  if ($_) {\n    parts.push(parseInt($_[1].padEnd(3, \"0\")));\n  } else {\n    parts.push(0);\n  }\n\n  const [days, hours, minutes, seconds, milliseconds] = parts;\n\n  return (\n    milliseconds + 1000 * (seconds + 60 * (minutes + 60 * (hours + 24 * days)))\n  );\n}\n\n/**\n * Format a duration as a {@link https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-duration-string time duration string}\n * for use as a {@link https://html.spec.whatwg.org/multipage/text-level-semantics.html#attr-time-datetime datetime} attribute.\n * @param time Duration in milliseconds.\n * @returns A duration string such as \"PT4H18M3S\".\n * @since 1.7.0\n */\nexport function formatTimeDuration(time: number): string {\n  const parts = [\"P\"];\n  const timeParts: string[] = [];\n\n  const days = Math.floor(time / DAYS),\n    hours = Math.floor((time / HOURS) % 24),\n    minutes = Math.floor((time / MINUTES) % 60),\n    seconds = (time / SECONDS) % 60;\n\n  if (days > 0) {\n    parts.push(`${days}D`);\n  }\n\n  if (hours > 0) {\n    timeParts.push(`${hours}H`);\n  }\n\n  if (minutes > 0) {\n    timeParts.push(`${minutes}M`);\n  }\n\n  if (seconds > 0) {\n    timeParts.push(`${seconds.toFixed(3).replace(/\\.?0+$/, \"\")}S`);\n  }\n\n  if (timeParts.length > 0) {\n    parts.push(\"T\", ...timeParts);\n  }\n\n  return parts.join(\"\");\n}\n\n/**\n * Format a time as \"mm:ss\"\n * @param time Time in milliseconds\n * @returns Formatted time\n */\nexport function formatTime(time: number): string {\n  if (time < 0) {\n    return MINUS_SIGN + formatTime(-time);\n  }\n  const days = Math.floor(time / DAYS),\n    hours = Math.floor((time / HOURS) % 24),\n    minutes = Math.floor((time / MINUTES) % 60),\n    seconds = Math.floor((time / SECONDS) % 60);\n\n  let firstNonzero = true;\n  let str = \"\";\n  for (const part of [days, hours, minutes]) {\n    if (firstNonzero) {\n      if (part !== 0) {\n        firstNonzero = false;\n        str += part.toString() + \":\";\n      }\n    } else {\n      str += part.toString().padStart(2, \"0\") + \":\";\n    }\n  }\n  // display 0:ss\n  if (firstNonzero) {\n    str += \"0:\";\n  }\n  str += seconds.toString().padStart(2, \"0\");\n  return str;\n}\n\n/**\n * Format a time as \"mm:ss.ms\"\n * @param time Time in milliseconds\n * @returns Formatted time\n */\nexport function formatTimeMs(time: number): string {\n  if (time < 0) {\n    return MINUS_SIGN + formatTimeMs(-time);\n  }\n  const milliseconds = Math.floor(time % 1000);\n\n  if (milliseconds === 0) {\n    return formatTime(time);\n  }\n\n  return (\n    formatTime(time) +\n    \".\" +\n    String(milliseconds).padStart(3, \"0\").replace(/0+$/, \"\")\n  );\n}\n"
  },
  {
    "path": "packages/utils/src/types.ts",
    "content": "/** Assert that a variable is defined. */\nexport function assertDefined<T>(a: T): asserts a is Exclude<T, undefined> {}\n\n/** Assert the type of a variable. */\nexport function assertType<K>(a: unknown): asserts a is K {}\n"
  },
  {
    "path": "packages/utils/tests/animation.test.ts",
    "content": "import {animate, bezier, replay} from \"../src/animation\";\nimport {ReplayData} from \"../src/replay-data\";\n\ndescribe(\"animation/animate\", () => {\n  test(\"defaults\", () => {\n    const fn = animate({startTime: 0, duration: 1000});\n    expect(fn(0)).toBe(0);\n    expect(fn(1000)).toBe(1);\n    expect(fn(500)).toBe(0.5);\n  });\n\n  test(\"basic test\", () => {\n    const fn = animate({\n      startTime: 1000,\n      duration: 1000,\n      startValue: 2,\n      endValue: 4,\n    });\n\n    expect(fn(500)).toBe(2);\n    expect(fn(3000)).toBe(4);\n    expect(fn(1500)).toBe(3);\n  });\n\n  test(\"called with array\", () => {\n    const fn = animate([\n      {startTime: 500, duration: 500, startValue: 2, endValue: 4},\n      {startTime: 1500, duration: 1000, startValue: 6, endValue: 8},\n    ]);\n\n    expect(fn(0)).toBe(2);\n    expect(fn(750)).toBe(3);\n    expect(fn(1200)).toBe(4);\n    expect(fn(2000)).toBe(7);\n  });\n});\n\ndescribe(\"animation/bezier\", () => {\n  test(\"bezier imported correctly\", () => {\n    expect(typeof bezier).toBe(\"function\");\n  });\n});\n\ndescribe(\"animation/replay\", () => {\n  test(\"compressed\", () => {\n    const data: ReplayData<string> = [\n      [0, \"a\"],\n      [500, \"b\"],\n      [500, \"c\"],\n    ];\n    const active = jest.fn();\n    const inactive = jest.fn();\n\n    const fn = replay({\n      data,\n      start: 500,\n      end: 2000,\n      compressed: true,\n      active,\n      inactive,\n    });\n\n    // functions shouldn't be called yet\n    expect(active).not.toHaveBeenCalled();\n    expect(inactive).not.toHaveBeenCalled();\n\n    // before start\n    fn(0);\n    expect(active).not.toHaveBeenCalled();\n    expect(inactive).toHaveBeenCalled();\n\n    // don't call inactive repeatedly\n    fn(0);\n    expect(inactive).toHaveBeenCalledTimes(1);\n\n    // active tests\n    fn(600);\n    expect(active).toHaveBeenLastCalledWith(\"a\", 0);\n    fn(1000);\n    expect(active).toHaveBeenLastCalledWith(\"b\", 1);\n    fn(1700);\n    expect(active).toHaveBeenLastCalledWith(\"c\", 2);\n\n    // inactive again\n    fn(2000);\n    fn(2000);\n    expect(inactive).toHaveBeenCalledTimes(2);\n  });\n\n  test(\"uncompressed\", () => {\n    const data: ReplayData<string> = [\n      [0, \"a\"],\n      [500, \"b\"],\n      [1000, \"c\"],\n    ];\n    const active = jest.fn();\n    const inactive = jest.fn();\n\n    const fn = replay({\n      data,\n      start: 500,\n      end: 2000,\n      compressed: false,\n      active,\n      inactive,\n    });\n\n    // functions shouldn't be called yet\n    expect(active).not.toHaveBeenCalled();\n    expect(inactive).not.toHaveBeenCalled();\n\n    // before start\n    fn(0);\n    expect(active).not.toHaveBeenCalled();\n    expect(inactive).toHaveBeenCalled();\n\n    // don't call inactive repeatedly\n    fn(0);\n    expect(inactive).toHaveBeenCalledTimes(1);\n\n    // active tests\n    fn(600);\n    expect(active).toHaveBeenLastCalledWith(\"a\", 0);\n    fn(1000);\n    expect(active).toHaveBeenLastCalledWith(\"b\", 1);\n    fn(1700);\n    expect(active).toHaveBeenLastCalledWith(\"c\", 2);\n\n    // inactive again\n    fn(2000);\n    fn(2000);\n    expect(inactive).toHaveBeenCalledTimes(2);\n  });\n\n  test(\"units\", () => {\n    const data: ReplayData<string> = [\n      [0, \"a\"],\n      [500, \"b\"],\n      [500, \"c\"],\n    ];\n    const active = jest.fn();\n    const inactive = jest.fn();\n\n    const fn = replay({\n      data,\n      start: 0.5,\n      end: 2,\n      compressed: true,\n      active,\n      inactive,\n      units: 1000,\n    });\n\n    // functions shouldn't be called yet\n    expect(active).not.toHaveBeenCalled();\n    expect(inactive).not.toHaveBeenCalled();\n\n    // before start\n    fn(0);\n    expect(active).not.toHaveBeenCalled();\n    expect(inactive).toHaveBeenCalled();\n\n    // don't call inactive repeatedly\n    fn(0);\n    expect(inactive).toHaveBeenCalledTimes(1);\n\n    // active tests\n    fn(0.6);\n    expect(active).toHaveBeenLastCalledWith(\"a\", 0);\n    fn(1);\n    expect(active).toHaveBeenLastCalledWith(\"b\", 1);\n    fn(1.7);\n    expect(active).toHaveBeenLastCalledWith(\"c\", 2);\n\n    // inactive again\n    fn(2);\n    fn(2);\n    expect(inactive).toHaveBeenCalledTimes(2);\n  });\n});\n"
  },
  {
    "path": "packages/utils/tests/json.test.ts",
    "content": "import {getJSON, loadAllJSON, loadJSON} from \"../src/json\";\n\ndeclare module \"../src/json\" {\n  interface GetJSONMap {\n    A: {value: string};\n    B: {value: string};\n    C: never;\n  }\n}\n\n/* mock fetch */\n// @ts-expect-error fetch is read-only\nglobal.fetch = jest.fn((href: string) =>\n  Promise.resolve({\n    json: () => Promise.resolve({value: href}),\n  }),\n);\n\nbeforeEach(() => {\n  // @ts-expect-error mockClear doesn't exist on the actual fetch\n  fetch.mockClear();\n\n  document.head.innerHTML = `\n    <link rel=\"preload\" type=\"application/json\" data-name=\"A\" href=\"./A.json\"/>\n    <link rel=\"preload\" type=\"application/json\" data-name=\"B\" href=\"./B.json\"/>\n    <link rel=\"preload\" type=\"application/json\" href=\"./C.json\"/>\n  `;\n});\n\ndescribe(\"json/*\", () => {\n  /* not found */\n  test(\"async not found\", () => {\n    expect(loadJSON(\"C\")).rejects.toEqual('JSON record \"C\" not found');\n  });\n\n  test(\"sync not found\", () => {\n    expect(() => getJSON(\"A\")).toThrow('JSON record \"A\" not loaded');\n  });\n\n  test(\"async found\", () => {\n    expect(loadJSON(\"A\")).resolves.toEqual({value: \"http://localhost/A.json\"});\n  });\n\n  test(\"async preload\", () => {\n    const promise = loadAllJSON().then(() => {\n      return [getJSON(\"A\"), getJSON(\"B\")];\n    });\n    expect(promise).resolves.toEqual([\n      {value: \"http://localhost/A.json\"},\n      {value: \"http://localhost/B.json\"},\n    ]);\n  });\n});\n"
  },
  {
    "path": "packages/utils/tests/misc.test.ts",
    "content": "import {\n  between,\n  bind,\n  clamp,\n  constrain,\n  lerp,\n  range,\n  wait,\n  waitFor,\n} from \"../src/misc\";\n\njest.useFakeTimers();\njest.spyOn(global, \"setTimeout\");\n\ndescribe(\"misc/between\", () => {\n  test(\"Lower bound inclusive\", () => {\n    expect(between(0, 0, 1)).toBe(true);\n  });\n\n  test(\"Upper bound exclusive\", () => {\n    expect(between(0, 1, 1)).toBe(false);\n  });\n\n  test(\"Between works\", () => {\n    expect(between(0, 0.5, 1)).toBe(true);\n  });\n\n  test(\"Below works\", () => {\n    expect(between(0, -1, 1)).toBe(false);\n  });\n\n  test(\"Above works\", () => {\n    expect(between(0, 2, 1)).toBe(false);\n  });\n});\n\ndescribe(\"misc/bind\", () => {\n  test(\"bind works\", () => {\n    const o = {\n      a() {\n        return this;\n      },\n    };\n    const p = {};\n    bind(o, [\"a\"]);\n\n    expect(o.a.call(p)).toBe(o);\n  });\n});\n\ndescribe(\"misc/lerp\", () => {\n  test(\"lerp works\", () => {\n    expect(lerp(1, 3, 0.5)).toBe(2);\n  });\n});\n\ndescribe(\"misc/clamp\", () => {\n  test(\"clamp below\", () => {\n    expect(clamp(0, -1, 5)).toBe(0);\n    expect(constrain(0, -1, 5)).toBe(0);\n  });\n\n  test(\"clamp neutral\", () => {\n    expect(clamp(0, 2, 5)).toBe(2);\n    expect(constrain(0, 2, 5)).toBe(2);\n  });\n\n  test(\"clamp above \", () => {\n    expect(clamp(0, 7, 5)).toBe(5);\n    expect(constrain(0, 7, 5)).toBe(5);\n  });\n});\n\ndescribe(\"misc/range\", () => {\n  test(\"two arguments\", () => {\n    expect(range(1, 4)).toEqual([1, 2, 3]);\n  });\n\n  test(\"one argument\", () => {\n    expect(range(3)).toEqual([0, 1, 2]);\n  });\n});\n\ndescribe(\"misc/wait\", () => {\n  test(\"waits 1 second\", () => {\n    let resolved = false;\n    const promise = wait(1000).then(() => (resolved = true));\n\n    // setTimeout should have been called but not resolved\n    expect(setTimeout).toHaveBeenCalledTimes(1);\n    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);\n    expect(resolved).toBe(false);\n\n    jest.runAllTimers();\n\n    // now callback should have been called\n    expect(promise).resolves.toBe(true);\n  });\n});\n\ndescribe(\"misc/waitFor\", () => {\n  test(\"waits for condition\", () => {\n    let val = 0;\n    let resolved = false;\n    const callback = jest.fn(() => val === 1);\n\n    const promise = waitFor(callback).then(() => (resolved = true));\n\n    // should be called once initially\n    expect(callback).toHaveBeenCalledTimes(1);\n    expect(resolved).toBe(false);\n\n    // run, fail\n    jest.runOnlyPendingTimers();\n    expect(callback).toHaveBeenCalledTimes(2);\n    expect(resolved).toBe(false);\n\n    // run again, fail\n    jest.runOnlyPendingTimers();\n    expect(callback).toHaveBeenCalledTimes(3);\n    expect(resolved).toBe(false);\n\n    // run again, succeed\n    val = 1;\n    jest.runOnlyPendingTimers();\n    expect(callback).toHaveBeenCalledTimes(4);\n    expect(promise).resolves.toBe(true);\n  });\n});\n"
  },
  {
    "path": "packages/utils/tests/react.test.tsx",
    "content": "import {render} from \"@testing-library/react\";\nimport {useContext} from \"react\";\nimport {createUniqueContext} from \"../src/react\";\n\ndescribe(\"react/createUniqueContext\", () => {\n  test(\"returns same value for same key\", () => {\n    const a = createUniqueContext(\"A\");\n    const b = createUniqueContext(\"A\");\n    expect(a).toBe(b);\n  });\n\n  test(\"returns different values for different keys\", () => {\n    const a = createUniqueContext(\"B\");\n    const b = createUniqueContext(\"C\");\n    expect(a).not.toBe(b);\n  });\n\n  test(\"provides correct default value\", () => {\n    const context = createUniqueContext<number>(\"D\", 4);\n\n    function Component(): null {\n      expect(useContext(context)).toBe(4);\n      return null;\n    }\n    render(<Component />);\n  });\n});\n"
  },
  {
    "path": "packages/utils/tests/time.test.ts",
    "content": "import {\n  formatTimeDuration,\n  formatTime,\n  formatTimeMs,\n  parseTime,\n} from \"../src/time\";\n\n/* time constants */\nconst SECONDS = 1000;\nconst MINUTES = 60 * SECONDS;\nconst HOURS = 60 * MINUTES;\nconst DAYS = 24 * HOURS;\n\nconst MINUS_SIGN = \"\\u2212\";\n\ndescribe(\"time/formatTimeDuration\", () => {\n  test(\"formats multi-day durations\", () => {\n    expect(formatTimeDuration(10 * DAYS)).toBe(\"P10D\");\n    expect(formatTimeDuration(10 * DAYS + 2 * HOURS)).toBe(\"P10DT2H\");\n    expect(formatTimeDuration(10 * DAYS + 1 * HOURS + 3 * MINUTES)).toBe(\n      \"P10DT1H3M\",\n    );\n    expect(\n      formatTimeDuration(10 * DAYS + 1 * HOURS + 10 * MINUTES + 3 * SECONDS),\n    ).toBe(\"P10DT1H10M3S\");\n    expect(\n      formatTimeDuration(\n        2 * DAYS + 23 * HOURS + 3 * MINUTES + 20 * SECONDS + 337,\n      ),\n    ).toBe(\"P2DT23H3M20.337S\");\n  });\n\n  test(\"formats sub-day durations\", () => {\n    expect(formatTimeDuration(1 * HOURS)).toBe(\"PT1H\");\n    expect(formatTimeDuration(10 * MINUTES)).toBe(\"PT10M\");\n    expect(formatTimeDuration(17 * HOURS + 23 * SECONDS)).toBe(\"PT17H23S\");\n    expect(formatTimeDuration(5 * MINUTES + 18 * SECONDS + 1)).toBe(\n      \"PT5M18.001S\",\n    );\n    expect(formatTimeDuration(5 * SECONDS)).toBe(\"PT5S\");\n    expect(formatTimeDuration(1 * SECONDS + 50)).toBe(\"PT1.05S\");\n  });\n});\ndescribe(\"time/formatTime\", () => {\n  // seconds\n  test(\"0:ss\", () => {\n    expect(formatTime(1 * SECONDS)).toBe(\"0:01\");\n  });\n\n  // minutes\n  test(\"m:ss\", () => {\n    expect(formatTime(1 * MINUTES + 1 * SECONDS)).toBe(\"1:01\");\n  });\n\n  test(\"mm:ss\", () => {\n    expect(formatTime(10 * MINUTES + 20 * SECONDS)).toBe(\"10:20\");\n  });\n\n  // hours\n  test(\"h:0m:ss\", () => {\n    expect(formatTime(1 * HOURS + 1 * MINUTES + 1 * SECONDS)).toBe(\"1:01:01\");\n  });\n\n  test(\"h:mm:ss\", () => {\n    expect(formatTime(1 * HOURS + 25 * MINUTES + 1 * SECONDS)).toBe(\"1:25:01\");\n  });\n\n  test(\"hh:mm:ss\", () => {\n    expect(formatTime(14 * HOURS + 25 * MINUTES + 1 * SECONDS)).toBe(\n      \"14:25:01\",\n    );\n  });\n\n  // days\n  test(\"dd:0h:mm:ss\", () => {\n    expect(formatTime(1 * DAYS + 3 * MINUTES + 4 * SECONDS)).toBe(\"1:00:03:04\");\n  });\n\n  // negative time\n  test(\"negative time\", () => {\n    expect(formatTime(-10 * MINUTES - 5 * SECONDS)).toBe(MINUS_SIGN + \"10:05\");\n  });\n});\n\ndescribe(\"time/formatTimeMs\", () => {\n  test(\"no milliseconds\", () => {\n    expect(formatTimeMs(2000)).toBe(\"0:02\");\n  });\n\n  test(\".00x\", () => {\n    expect(formatTimeMs(3)).toBe(\"0:00.003\");\n  });\n\n  test(\".0xx\", () => {\n    expect(formatTimeMs(1 * MINUTES + 37 * SECONDS + 25)).toBe(\"1:37.025\");\n  });\n\n  test(\".xxx\", () => {\n    expect(formatTimeMs(1 * HOURS + 2 * MINUTES + 371)).toBe(\"1:02:00.371\");\n  });\n\n  test(\".x\", () => {\n    expect(formatTimeMs(10 * MINUTES + 4 * SECONDS + 300)).toBe(\"10:04.3\");\n  });\n\n  test(\".xx\", () => {\n    expect(formatTimeMs(8 * MINUTES + 23 * SECONDS + 420)).toBe(\"8:23.42\");\n  });\n});\n\ndescribe(\"time/parseTime\", () => {\n  test(\"milliseconds\", () => {\n    expect(parseTime(\"1:00.5\")).toBe(60500);\n    expect(parseTime(\"1:00.02\")).toBe(60020);\n    expect(parseTime(\"1:00.131\")).toBe(60131);\n  });\n\n  // seconds\n  test(\"0:ss\", () => {\n    expect(parseTime(\"0:01\")).toBe(1 * SECONDS);\n  });\n\n  // minutes\n  test(\"m:ss\", () => {\n    expect(parseTime(\"1:01\")).toBe(1 * MINUTES + 1 * SECONDS);\n  });\n\n  test(\"mm:ss\", () => {\n    expect(parseTime(\"10:20\")).toBe(10 * MINUTES + 20 * SECONDS);\n  });\n\n  // hours\n  test(\"h:0m:ss\", () => {\n    expect(parseTime(\"1:01:01\")).toBe(1 * HOURS + 1 * MINUTES + 1 * SECONDS);\n  });\n\n  test(\"h:mm:ss\", () => {\n    expect(parseTime(\"1:25:01\")).toBe(1 * HOURS + 25 * MINUTES + 1 * SECONDS);\n  });\n\n  test(\"hh:mm:ss\", () => {\n    expect(parseTime(\"14:25:01\")).toBe(14 * HOURS + 25 * MINUTES + 1 * SECONDS);\n  });\n\n  // days\n  test(\"dd:0h:mm:ss\", () => {\n    expect(parseTime(\"1:00:03:04\")).toBe(1 * DAYS + 3 * MINUTES + 4 * SECONDS);\n  });\n\n  // negative time\n  test(\"negative time\", () => {\n    expect(parseTime(\"-10:05\")).toBe(-10 * MINUTES - 5 * SECONDS);\n    expect(parseTime(MINUS_SIGN + \"10:05\")).toBe(-10 * MINUTES - 5 * SECONDS);\n  });\n});\n"
  },
  {
    "path": "packages/utils/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "packages/xyjax/README.md",
    "content": "# @liqvid/xyjax\n\nHelpers for animating [xyjax](https://sonoisa.github.io/xyjax/xyjax.html) diagrams in [Liqvid](https://liqvidjs.org).\n"
  },
  {
    "path": "packages/xyjax/package.json",
    "content": "{\n  \"name\": \"@liqvid/xyjax\",\n  \"version\": \"0.0.2\",\n  \"description\": \"XyJax animation helpers for Liqvid\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.mjs\",\n      \"require\": \"./dist/index.cjs\"\n    }\n  },\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./dist/*\"]\n    }\n  },\n  \"files\": [\"dist/*\"],\n  \"author\": \"Yuri Sulyma <yuri@liqvidjs.org>\",\n  \"keywords\": [\"liqvid\", \"mathjax\", \"xyjax\"],\n  \"scripts\": {\n    \"build\": \"pnpm build:clean && pnpm build:js && pnpm build:postclean\",\n    \"build:clean\": \"rm -rf dist\",\n    \"build:js\": \"tsc; node ../../build.mjs; rollup -c\",\n    \"build:postclean\": \"rm -rf dist/esm dist/types dist/tsconfig.tsbuildinfo\",\n    \"lint\": \"eslint --ext ts,tsx --fix src\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/liqvidjs/liqvid.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/liqvidjs/liqvid/issues\"\n  },\n  \"homepage\": \"https://github.com/liqvidjs/liqvid/tree/main/packages/xyjax\",\n  \"license\": \"MIT\",\n  \"peerDependencies\": {\n    \"@liqvid/mathjax\": \"workspace:^\",\n    \"@types/react\": \">=17.0.0\",\n    \"liqvid\": \"workspace:^\",\n    \"react\": \">=17.0.0\"\n  },\n  \"devDependencies\": {\n    \"@liqvid/mathjax\": \"workspace:^\",\n    \"liqvid\": \"workspace:^\"\n  },\n  \"dependencies\": {\n    \"@liqvid/utils\": \"workspace:^\"\n  }\n}\n"
  },
  {
    "path": "packages/xyjax/rollup.config.js",
    "content": "import dts from \"rollup-plugin-dts\";\n\nconst external = [\n  \"@liqvid/mathjax\",\n  \"@liqvid/utils/animation\",\n  \"@liqvid/utils/misc\",\n  \"liqvid\",\n  \"react\",\n];\n\nexport default [\n  // index\n  {\n    external,\n    input: \"dist/esm/index.mjs\",\n\n    output: [\n      // ESM\n      {file: \"./dist/index.mjs\", format: \"esm\"},\n      // CJS\n      {file: \"./dist/index.cjs\", format: \"cjs\"},\n    ],\n  },\n  // types\n  {\n    input: \"dist/types/index.d.ts\",\n    plugins: [dts()],\n    output: {\n      file: \"dist/index.d.ts\",\n      format: \"es\",\n    },\n  },\n];\n"
  },
  {
    "path": "packages/xyjax/src/index.ts",
    "content": "/**\n  XyJax shenanigans\n*/\n\nimport {lerp} from \"@liqvid/utils/misc\";\nimport {usePlayback, useTime} from \"liqvid\";\nimport {useCallback, useEffect, useRef} from \"react\";\nimport {Handle} from \"@liqvid/mathjax\";\n\ninterface Coords {\n  x1?: number;\n  y1?: number;\n  x2?: number;\n  y2?: number;\n}\n\n/**\n * Animate XyJax arrows\n */\nexport function useAnimateArrows(\n  o: {\n    /** CSS selector for arrow head */\n    head: string;\n\n    /** CSS selector for arrow tail */\n    tail: string;\n\n    /** CSS selector for arrow label */\n    label?: string;\n\n    /** Reference to container {@link MJX} */\n    ref: React.MutableRefObject<Handle>;\n\n    /** Animation function for arrow tail */\n    tailFn: (t: number) => number;\n\n    /** Fade options for arrow head */\n    headFade: KeyframeEffectOptions;\n\n    /** Fade options for arrow label */\n    labelFade: KeyframeEffectOptions;\n  },\n  deps?: React.DependencyList,\n): void {\n  const playback = usePlayback();\n  const tail = useRef<SVGLineElement>();\n  const init = useRef<Coords>({});\n\n  /* fading function */\n  const fadeTail = useCallback((u: number) => {\n    if (!tail.current) return;\n\n    const {x1, x2, y1, y2} = init.current;\n\n    if (u === 0) {\n      tail.current.style.opacity = \"0\";\n    } else {\n      tail.current.style.opacity = \"1\";\n      tail.current.setAttribute(\"x2\", lerp(x1, x2, u).toString());\n      tail.current.setAttribute(\"y2\", lerp(y1, y2, u).toString());\n    }\n  }, []);\n\n  /* initialize */\n  useEffect(() => {\n    o.ref.current.ready.then(() => {\n      /* tail animation */\n      tail.current = o.ref.current.domElement.querySelector(o.tail);\n\n      for (const key of [\"x1\", \"y1\", \"x2\", \"y2\"] as (keyof Coords)[]) {\n        init.current[key] = parseFloat(tail.current.getAttribute(key));\n      }\n\n      fadeTail(o.tailFn(playback.currentTime));\n\n      /* head animation */\n      const headNodes = Array.from(\n        o.ref.current.domElement.querySelectorAll(o.head),\n      );\n      for (const head of headNodes) {\n        playback.newAnimation([{opacity: 0}, {opacity: 1}], o.headFade)(head);\n      }\n\n      /* label animation */\n      const labelNodes = Array.from(\n        o.ref.current.domElement.querySelectorAll(o.label),\n      );\n      for (const label of labelNodes) {\n        playback.newAnimation([{opacity: 0}, {opacity: 1}], o.labelFade)(label);\n      }\n    });\n  }, deps);\n\n  // tail animation\n  useTime(fadeTail, o.tailFn, deps);\n}\n\n// absolutely bonkers interception\nlet extended = false;\nObject.defineProperty(MathJax, \"AST\", {\n  get() {\n    return this.__xypic;\n  },\n\n  set(value) {\n    this.__xypic = value;\n    if (!extended) {\n      extendXY();\n      extended = true;\n    }\n    return (this.__xypic = value);\n  },\n});\n\nexport function extendXY(): void {\n  const AST = MathJax.AST;\n  const xypic = MathJax.xypicGlobalContext;\n  const {modifierRepository} = xypic.repositories;\n\n  /* inject ourselves into xypic */\n  const prototype = AST.Modifier.Shape.Alphabets.prototype;\n\n  const preprocess = prototype.preprocess.bind(prototype);\n  prototype.preprocess = function (...args: unknown[]) {\n    if (this.alphabets.startsWith(\"color\")) {\n      return modifierRepository.get(\"color\").preprocess(...args);\n    } else if (this.alphabets.startsWith(\"data\")) {\n      return modifierRepository.get(\"data\").preprocess(...args);\n    }\n    return preprocess(...args);\n  };\n\n  const modifyShape = prototype.modifyShape.bind(prototype);\n  prototype.modifyShape = function (...args: unknown[]) {\n    if (this.alphabets.startsWith(\"color\")) {\n      const color = this.alphabets.substr(\"color\".length);\n      return modifierRepository.get(\"color\").modifyShape(...args, color);\n    } else if (this.alphabets.startsWith(\"data\")) {\n      const data = this.alphabets.substr(\"data\".length);\n      return modifierRepository.get(\"data\").modifyShape(...args, data);\n    }\n    return modifyShape(...args);\n  };\n\n  // color\n  modifierRepository.put(\n    \"color\",\n    new (class extends AST.Modifier.Shape.ChangeColor {\n      modifyShape(\n        context: unknown,\n        objectShape: unknown,\n        restModifiers: unknown,\n        color: string,\n      ) {\n        this.colorName = xyDecodeColor(color);\n        return super.modifyShape(context, objectShape, restModifiers);\n      }\n    })(),\n  );\n\n  class ChangeDataShape {\n    constructor(\n      public data: string,\n      public shape: any,\n    ) {}\n\n    draw(svg: any) {\n      const g = svg.createGroup();\n      Object.assign(\n        g.drawArea.dataset,\n        JSON.parse(\"{\" + fromb52(this.data) + \"}\"),\n      );\n      this.shape.draw(g);\n    }\n\n    getBoundingBox() {\n      return this.shape.getBoundingBox();\n    }\n  }\n\n  // data\n  modifierRepository.put(\n    \"data\",\n    new (class extends AST.Modifier {\n      preprocess() {}\n\n      modifyShape(\n        context: unknown,\n        objectShape: unknown,\n        restModifiers: unknown,\n        data: string,\n      ) {\n        objectShape = this.proceedModifyShape(\n          context,\n          objectShape,\n          restModifiers,\n        );\n        return new ChangeDataShape(data, objectShape);\n      }\n    })(),\n  );\n}\n\nconst MAP = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\";\nfunction to_b58(B: Uint8Array, A: string) {\n  let d: number[] = [],\n    s = \"\",\n    j: number,\n    c: number,\n    n: number;\n  for (let i = 0; i < B.length; ++i) {\n    (j = 0), (c = B[i]);\n    s += c || s.length ^ i ? \"\" : 1;\n    while (j in d || c) {\n      n = d[j];\n      n = n ? n * 256 + c : c;\n      c = (n / A.length) | 0;\n      d[j] = n % A.length;\n      j++;\n    }\n  }\n  while (j--) s += A[d[j]];\n  return s;\n}\n\nfunction from_b58(S: string, A: string) {\n  let d: number[] = [],\n    b = [],\n    j: number,\n    c: number,\n    n: number;\n  for (let i = 0; i < S.length; ++i) {\n    (j = 0), (c = A.indexOf(S[i]));\n    if (c < 0) return undefined;\n    c || b.length ^ i ? i : b.push(0);\n    while (j in d || c) {\n      n = d[j];\n      n = n ? n * A.length + c : c;\n      c = n >> 8;\n      d[j] = n % 256;\n      j++;\n    }\n  }\n  while (j--) b.push(d[j]);\n  return new Uint8Array(b);\n}\n\n/**\n * Encode a hex color for XyJax\n */\nexport function xyEncodeColor(color: string): string {\n  return color.toUpperCase().replace(/[#0-9]/g, (char) => {\n    if (char === \"#\") return \"\";\n    return String.fromCharCode(\"G\".charCodeAt(0) + parseInt(char));\n  });\n}\n\n/**\n * Decode a hex color for XyJax\n */\nexport function xyDecodeColor(color: string): string {\n  return (\n    \"#\" +\n    color.replace(/[G-P]/g, (digit) => {\n      return (digit.charCodeAt(0) - \"G\".charCodeAt(0)).toString();\n    })\n  );\n}\n\n/**\n * Encode an object for XyJax\n */\nexport function tob52(str: string): string {\n  const arr: number[] = [];\n  for (let i = 0; i < str.length; ++i) {\n    arr[i] = str.charCodeAt(i);\n  }\n  return to_b58(new Uint8Array(arr), MAP);\n}\n\n/**\n * Decode an object for XyJax\n */\nexport function fromb52(str: string): string {\n  const arr = from_b58(str, MAP);\n  let ret = \"\";\n  for (let i = 0; i < arr.length; ++i) {\n    ret += String.fromCharCode(arr[i]);\n  }\n  return ret;\n}\n"
  },
  {
    "path": "packages/xyjax/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"declarationDir\": \"./dist/types\",\n    \"module\": \"esnext\",\n    \"outDir\": \"./dist/esm\",\n    \"rootDir\": \"./src\",\n    \"target\": \"esnext\"\n  },\n  \"include\": [\"./src\"]\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  # all packages in subdirs of packages/\n  - \"packages/**\"\n\ncatalog:\n  \"@lqv/playback\": \"^0.2.2\"\n\n  # devDependencies\n  \"@biomejs/biome\": 2.3.8\n\ncatalogs:\n  peer:\n    \"@types/react\": \">=18\"\n    \"@types/react-dom\": \">=18\"\n    react: \">=18\"\n    react-dom: \">=18\"\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"alwaysStrict\": true,\n    \"declaration\": true,\n    \"esModuleInterop\": true,\n    \"incremental\": true,\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\"esnext\", \"dom\"],\n    \"module\": \"commonjs\",\n    \"moduleResolution\": \"node\",\n    \"noImplicitAny\": true,\n    \"pretty\": true,\n    \"removeComments\": false,\n    \"target\": \"es2021\"\n  }\n}\n"
  }
]