{"id":51452624,"url":"https://github.com/justintanner/videocity-engine","last_synced_at":"2026-07-05T22:30:29.449Z","repository":{"id":363802935,"uuid":"1172219404","full_name":"justintanner/videocity-engine","owner":"justintanner","description":"TypeScript filesystem abstraction for video/image asset projects with Git version control and distributed locking","archived":false,"fork":false,"pushed_at":"2026-06-10T11:41:21.000Z","size":13347,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-10T13:05:49.822Z","etag":null,"topics":["filesystem","git","nodejs","typescript","video-processing"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/justintanner.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-03-04T04:07:54.000Z","updated_at":"2026-06-10T11:41:28.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/justintanner/videocity-engine","commit_stats":null,"previous_names":["justintanner/vc-engine"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/justintanner/videocity-engine","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/justintanner%2Fvideocity-engine","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/justintanner%2Fvideocity-engine/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/justintanner%2Fvideocity-engine/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/justintanner%2Fvideocity-engine/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/justintanner","download_url":"https://codeload.github.com/justintanner/videocity-engine/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/justintanner%2Fvideocity-engine/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35171812,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-05T02:00:06.290Z","response_time":100,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["filesystem","git","nodejs","typescript","video-processing"],"created_at":"2026-07-05T22:30:28.728Z","updated_at":"2026-07-05T22:30:29.441Z","avatar_url":"https://github.com/justintanner.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# videocity-engine\n\n[![CI](https://github.com/justintanner/videocity-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/justintanner/videocity-engine/actions/workflows/ci.yml)\n[![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)\n[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue)](https://www.typescriptlang.org)\n\nTypeScript/Node.js filesystem abstraction for managing video, image, audio, script, and character asset projects. Each project is a git repo with a sidecar `.videocity/` directory holding two SQLite databases: `state.sqlite` for ephemeral coordination (locks, job queue, recovery journal) and `metadata.sqlite` for content metadata (timeline, characters, asset metadata, audio waveforms). Every mutation produces an atomic git commit; metadata changes are mirrored to canonical JSON exports under `.videocity/export/` so git stays diffable.\n\n## Install\n\n```bash\nnpm install videocity-engine\n```\n\n## Quick Start\n\n```typescript\nimport { createFs } from 'videocity-engine';\n\nconst fs = createFs({ projectsDir: '/path/to/projects' });\n\n// Create a project (auto-generated slug)\nconst project = await fs.createProject();\nif (!project.ok) {\n  console.error(project.error.code, project.error.message);\n  process.exit(1);\n}\nconst slug = project.value.slug; // \"bright-falcon-42\"\n\n// Create a video asset inside the project\nconst asset = await fs.createAsset('vid', 'intro-clip', slug);\nif (asset.ok) {\n  console.log(asset.value.assetId); // \"vid-intro-clip\"\n}\n\n// Write and read files (each write is an atomic git commit)\nawait fs.writeFile('vid-intro-clip', 'thumbnail.png', imageBuffer, slug);\nconst file = await fs.readFile('vid-intro-clip', 'thumbnail.png', slug);\nif (file.ok) {\n  console.log(file.value.length);\n}\n```\n\n## Result Pattern\n\nAll mutating methods return `Result\u003cT, FsError\u003e` instead of throwing exceptions:\n\n```typescript\ntype Result\u003cT, E\u003e = { ok: true; value: T } | { ok: false; error: E };\n\ninterface FsError {\n  code: FsErrorCode;\n  message: string;\n}\n```\n\n**Error codes:** `NOT_FOUND` | `ALREADY_EXISTS` | `GIT_ERROR` | `INVALID_INPUT` | `IO_ERROR` | `LOCKED`\n\n## Asset prefixes\n\nAssets live as prefixed directories at the project root. Valid prefixes:\n\n| Prefix | Type |\n|--------|------|\n| `vid-` | video |\n| `img-` | image |\n| `aud-` | audio |\n| `script-` | script |\n| `char-` | character |\n\nThe asset id `final` is reserved as a project-level singleton.\n\n## API\n\nAll methods are available on the object returned by `createFs(config)`. Unless noted otherwise, every method that takes a `projectSlug` requires a project that already exists.\n\n### Project\n\n| Method | Return Type |\n|--------|------------|\n| `createProject(slug?)` | `Promise\u003cResult\u003c{ slug, path, is_default }, FsError\u003e\u003e` |\n| `listProjects(options?)` | `Promise\u003cProjectMetadata[]\u003e` |\n| `getProject(slug?)` | `Promise\u003cResult\u003c{ metadata, path }, FsError\u003e\u003e` |\n| `switchProject(slug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `renameProject(oldSlug, newSlug)` | `Promise\u003cResult\u003c{ oldSlug, newSlug, path }, FsError\u003e\u003e` |\n| `resolveProjectDir(slug?)` | `Promise\u003cstring \\| null\u003e` |\n\n### Asset\n\n| Method | Return Type |\n|--------|------------|\n| `createAsset(prefix, name, projectSlug)` | `Promise\u003cResult\u003c{ assetId, path }, FsError\u003e\u003e` |\n| `listAssets(projectSlug, options?)` | `Promise\u003cAssetEntry[]\u003e` |\n| `deleteAsset(assetId, projectSlug)` | `Promise\u003cResult\u003c{ deleted_at }, FsError\u003e\u003e` |\n| `renameAsset(assetId, newName, projectSlug)` | `Promise\u003cResult\u003c{ old_asset_id, new_asset_id }, FsError\u003e\u003e` |\n| `getManifest(assetId, projectSlug, options?)` | `Promise\u003cResult\u003cAssetManifest, FsError\u003e\u003e` |\n| `getAssetStatus(assetId, projectSlug, options?)` | `Promise\u003cResult\u003cAssetStatus, FsError\u003e\u003e` |\n| `listAssetSubdir(assetId, subdirName, projectSlug)` | `Promise\u003cResult\u003cstring[], FsError\u003e\u003e` |\n| `slugTaken(slug, projectSlug)` | `Promise\u003cboolean\u003e` |\n\n### File\n\n| Method | Return Type |\n|--------|------------|\n| `writeFile(assetId, filename, data, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `readFile(assetId, filename, projectSlug)` | `Promise\u003cResult\u003cBuffer, FsError\u003e\u003e` |\n| `deleteFile(assetId, filename, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `renameFile(assetId, oldFilename, newFilename, projectSlug)` | `Promise\u003cResult\u003c{ oldPath, newPath }, FsError\u003e\u003e` |\n| `copyFile(assetId, filename, destAssetId, destFilename, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `resolveAssetDir(assetId, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n\n### Metadata\n\n`writeMetadata(assetId, 'character', record, ...)` is special-cased: the record is stored in the typed `characters` table in `metadata.sqlite`. Other keys are stored in the generic `asset_metadata` table. In both cases a `.{key}.json` sidecar is also written next to the asset and the operation produces a single git commit covering the SQLite file, the canonical export, and the sidecar.\n\n`writeProjectMeta(key, data, ...)` with `key === 'timeline'` is also special-cased and **strict**: `data` must be `{ slots: [{ slug, ... }, ...], render: 'landscape' | 'portrait' | 'square' }` (an optional `currentOrientation` and an optional `audio` array are accepted). Non-conforming payloads return `INVALID_INPUT`. Reads come from SQLite only — there is no sidecar fallback. Other project-meta keys are written as plain `.{key}.json` sidecars.\n\n| Method | Return Type |\n|--------|------------|\n| `writeMetadata(assetId, key, data, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `readMetadata\u003cT\u003e(assetId, key, projectSlug)` | `Promise\u003cResult\u003cT, FsError\u003e\u003e` |\n| `writeAudioWaveform(assetId, peaks, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `readAudioWaveform(assetId, projectSlug)` | `Promise\u003cResult\u003cAudioWaveformRecord, FsError\u003e\u003e` |\n| `writeProjectMeta(key, data, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `readProjectMeta\u003cT\u003e(key, projectSlug)` | `Promise\u003cResult\u003cT, FsError\u003e\u003e` |\n\n### Git\n\n| Method | Return Type |\n|--------|------------|\n| `commitOperation(operation, assetId?, details?, projectSlug)` | `Promise\u003cstring \\| null\u003e` |\n| `getHistory(projectSlug, limit?)` | `Promise\u003cGitCommit[]\u003e` |\n| `getAssetHistory(assetId, projectSlug, limit?)` | `Promise\u003cGitCommit[]\u003e` |\n| `restoreAsset(assetId, commitHash, projectSlug)` | `Promise\u003cstring \\| null\u003e` |\n| `readFileAtCommit(assetId, filename, commitHash, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `rewindProject(commitHash, projectSlug)` | `Promise\u003cstring \\| null\u003e` |\n\n### Lock\n\nLocks are SQLite rows in `state.sqlite`, one per asset directory (or one project-level lock keyed by `__PROJECT__`). The `assetDir` argument is an absolute filesystem path; the lock module resolves it back to `(projectDir, assetKey)`.\n\n| Method | Return Type |\n|--------|------------|\n| `acquireLock(assetDir, options)` | `Promise\u003cResult\u003cLockData, FsError\u003e\u003e` |\n| `releaseLock(assetDir)` | `Promise\u003cResult\u003cboolean, FsError\u003e\u003e` |\n| `isLocked(assetDir)` | `Promise\u003cboolean\u003e` |\n| `getLockData(assetDir)` | `Promise\u003cLockData \\| null\u003e` |\n| `cleanStaleLock(assetDir)` | `Promise\u003cboolean\u003e` |\n\n`LockOptions` is `{ durationMs, data?, state? }`. `acquireLock` returns `LOCKED` if a non-expired lock is already held; expired or dead-pid locks are reaped automatically on the next acquire (or explicitly via `cleanStaleLock`).\n\n### Action log\n\nAppend-only audit trail backed by git commits with structured payloads.\n\n| Method | Return Type |\n|--------|------------|\n| `logAction(action, payload, projectSlug)` | `Promise\u003cResult\u003cActionLogEntry, FsError\u003e\u003e` |\n| `getActionLog(options?, projectSlug)` | `Promise\u003cActionLogEntry[]\u003e` |\n\n### Generic JSONL log\n\nPer-project append-only logs under `logs/{name}.jsonl`. Not committed to git.\n\n| Method | Return Type |\n|--------|------------|\n| `appendLog(name, line, projectSlug)` | `Promise\u003cResult\u003cstring, FsError\u003e\u003e` |\n| `readLog(name, projectSlug, options?)` | `Promise\u003cRecord\u003cstring, unknown\u003e[]\u003e` |\n\n### Queue\n\nA persistent job queue lives in `state.sqlite` (`pending_jobs`). Jobs are dedupe-keyed, support external task ids, and are leased with heartbeats so a `QueueRunner` can be coordinated across multiple processes. `fs.queue` exposes the project-scoped surface; the standalone `queueApi` and `QueueRunner` are also exported for callers that want the raw `Database` handle.\n\n```typescript\nconst enq = await fs.queue.enqueue(slug, {\n  type: 'transcode',\n  assetId: 'vid-intro-clip',\n  payload: { preset: '1080p' },\n});\n\nconst result = await fs.queue.enqueueAndWait\u003cRenderResult\u003e(slug, {\n  type: 'render',\n  payload: { orientation: 'portrait' },\n});\n```\n\n### Pending tasks\n\nTracks external long-running provider jobs (transcription, generation, etc.) in the `pending_tasks` table of `state.sqlite`. Each row is keyed by `assetId`; `taskId` is the provider's task identifier (or `QUEUED_TASK_ID` for jobs that haven't been submitted yet). `createdAt` is **epoch seconds**.\n\n```typescript\nimport { QUEUED_TASK_ID } from 'videocity-engine';\n\nawait fs.pendingTasks.write(slug, {\n  assetId: 'vid-clip-1',\n  taskId: QUEUED_TASK_ID,\n  taskType: 'fal_seedance2_t2v',\n  assetDir: '/path/to/projects/proj-x/vid-clip-1',\n  meta: { prompt: 'sunset over ocean' },\n});\n\n// Atomic: write a generation_errors row + delete the pending_tasks row.\nawait fs.pendingTasks.fail(slug, 'vid-clip-1', { message: 'provider rejected', failCode: 'NSFW' });\n```\n\n| Method | Return Type |\n|--------|------------|\n| `pendingTasks.write(projectSlug, input)` | `Promise\u003cResult\u003cPendingTask, FsError\u003e\u003e` |\n| `pendingTasks.read(projectSlug, assetId)` | `Promise\u003cResult\u003cPendingTask \\| null, FsError\u003e\u003e` |\n| `pendingTasks.delete(projectSlug, assetId)` | `Promise\u003cResult\u003cboolean, FsError\u003e\u003e` |\n| `pendingTasks.markCompleting(projectSlug, assetId)` | `Promise\u003cResult\u003cboolean, FsError\u003e\u003e` |\n| `pendingTasks.clearCompleting(projectSlug, assetId)` | `Promise\u003cResult\u003cboolean, FsError\u003e\u003e` |\n| `pendingTasks.findAll(projectSlug)` | `Promise\u003cResult\u003cPendingTask[], FsError\u003e\u003e` |\n| `pendingTasks.findByExternalId(projectSlug, taskId)` | `Promise\u003cResult\u003cPendingTask \\| null, FsError\u003e\u003e` |\n| `pendingTasks.fail(projectSlug, assetId, info)` | `Promise\u003cResult\u003cGenerationError, FsError\u003e\u003e` |\n\n### Generation errors\n\nSibling table to `pending_tasks` for terminal failures. `failedAt` is **epoch seconds**.\n\n| Method | Return Type |\n|--------|------------|\n| `generationErrors.write(projectSlug, assetId, info)` | `Promise\u003cResult\u003cGenerationError, FsError\u003e\u003e` |\n| `generationErrors.read(projectSlug, assetId)` | `Promise\u003cResult\u003cGenerationError \\| null, FsError\u003e\u003e` |\n| `generationErrors.clear(projectSlug, assetId)` | `Promise\u003cResult\u003cboolean, FsError\u003e\u003e` |\n| `generationErrors.findAll(projectSlug)` | `Promise\u003cResult\u003cGenerationError[], FsError\u003e\u003e` |\n\n### Lifecycle\n\n| Method | Return Type |\n|--------|------------|\n| `ensureProjectInitialized(slug)` | `Promise\u003cvoid\u003e` — idempotently create `.videocity/`, open the state DB, apply gitignore patterns. Safe to call on every boot or enqueue. |\n| `recoverIncompleteOperations(slug)` | `Promise\u003cnumber\u003e` — drain the recovery journal at startup |\n| `checkSchemaVersion(slug)` | `Promise\u003cVersionCheckResult\u003e` — refuse to open a project written by a newer build |\n| `close()` | `void` — close all SQLite handles before process exit |\n\n## Atomic operations \u0026 recovery\n\nEvery metadata write follows the same shape:\n\n1. Acquire the per-project git mutex (`proper-lockfile` on `.videocity/.project.lock`).\n2. Open a `recovery_journal` row, run the SQLite work in a transaction, and rebuild the affected canonical JSON exports under `.videocity/export/`.\n3. Stage the SQLite file, exports, and any sidecars in a single `git commit` whose body includes `op-id: \u003cuuid\u003e`.\n4. Mark the journal row complete.\n\nIf the process dies mid-operation, `recoverIncompleteOperations()` replays the journal at startup: it re-derives the exports from `metadata.sqlite`, writes any missing sidecars, and produces a fresh `recover` commit. Sentinel `op-id` lookups in `git log` ensure idempotency. Schema migrations are checksummed in `schema_migrations`; mismatches are fatal.\n\n## Configuration\n\n```typescript\ninterface FsConfig {\n  projectsDir: string;  // Root directory for all projects\n  gitPath?: string;     // Custom git binary path (default: \"git\")\n}\n```\n\n## Development\n\n```bash\nnpm run build        # Compile TypeScript\nnpm run typecheck    # Type-check without emitting\nnpm test             # Run tests\nnpm run test:watch   # Run tests in watch mode\nnpm run bench        # Run vitest benches\n```\n\n## Requirements\n\n- Node.js \u003e= 20\n- Git\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjustintanner%2Fvideocity-engine","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjustintanner%2Fvideocity-engine","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjustintanner%2Fvideocity-engine/lists"}