Adding gnommoeditor in the current version

This commit is contained in:
2026-04-11 09:24:21 +02:00
commit 53d310da82
237 changed files with 64938 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Stage 1: build the Vite app
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# VITE_ env vars are baked into the bundle at build time
ARG VITE_GNOMMOWEB_URL
ENV VITE_GNOMMOWEB_URL=${VITE_GNOMMOWEB_URL}
RUN npm run build
# Stage 2: serve with nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/nginx.prod.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
COPY migrations ./migrations
COPY migrate-config.cjs ./
COPY run-migrations.cjs ./
EXPOSE 3001
CMD ["npm", "start"]
+19
View File
@@ -0,0 +1,19 @@
/**
* node-pg-migrate configuration
*/
if (!process.env.DATABASE_URL) {
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
}
module.exports = {
databaseUrl: process.env.DATABASE_URL,
migrationsTable: 'pgmigrations',
dir: 'migrations',
direction: 'up',
count: Infinity,
verbose: true,
decamelize: true,
'ignore-pattern': '^\\..*',
};
+28
View File
@@ -0,0 +1,28 @@
exports.up = (pgm) => {
pgm.createTable('videos', {
id: 'id',
gnommo_id: { type: 'varchar(50)', notNull: true, unique: true },
course_code: { type: 'varchar(100)' },
title: { type: 'varchar(500)', notNull: true },
description: { type: 'text' },
hook: { type: 'text' },
status: { type: 'varchar(50)' },
resolution_width: { type: 'integer' },
resolution_height: { type: 'integer' },
fps: { type: 'integer' },
default_slide_type: { type: 'varchar(50)' },
background: { type: 'varchar(500)' },
platform_targets: { type: 'jsonb' },
duration_seconds: { type: 'numeric' },
youtube_url: { type: 'varchar(500)' },
raw_project_json: { type: 'jsonb' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('videos', 'gnommo_id', { name: 'idx_videos_gnommo_id' });
};
exports.down = (pgm) => {
pgm.dropTable('videos');
};
+30
View File
@@ -0,0 +1,30 @@
exports.up = (pgm) => {
pgm.createTable('slides', {
id: 'id',
video_id: {
type: 'integer',
notNull: true,
references: 'videos',
onDelete: 'CASCADE',
},
gnommo_slide_id: { type: 'varchar(20)', notNull: true },
slide_order: { type: 'integer', notNull: true },
image_filename: { type: 'varchar(500)' },
display_mode: { type: 'varchar(50)' },
component_key: { type: 'varchar(100)' },
props: { type: 'jsonb' },
presenter_notes: { type: 'text' },
start_time_sec: { type: 'numeric' },
end_time_sec: { type: 'numeric' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.addConstraint('slides', 'slides_video_id_gnommo_slide_id_unique', 'UNIQUE (video_id, gnommo_slide_id)');
pgm.createIndex('slides', 'video_id', { name: 'idx_slides_video_id' });
pgm.createIndex('slides', ['video_id', 'slide_order'], { name: 'idx_slides_video_order' });
};
exports.down = (pgm) => {
pgm.dropTable('slides');
};
+17
View File
@@ -0,0 +1,17 @@
// Extend videos table with fields from project.json not captured in 001
exports.up = (pgm) => {
pgm.addColumns('videos', {
output_video: { type: 'varchar(200)' },
keynote_file: { type: 'varchar(500)' },
parent_gnommo_id: { type: 'varchar(50)' }, // gnommo_id of parent (for shorts)
outro: { type: 'jsonb' }, // ["outrovideo1"]
shorts: { type: 'jsonb' }, // ["short_pixelated_universe", ...]
manuscript_path: { type: 'varchar(500)' }, // "manuscript.txt"
});
};
exports.down = (pgm) => {
pgm.dropColumns('videos', [
'output_video', 'keynote_file', 'parent_gnommo_id', 'outro', 'shorts', 'manuscript_path',
]);
};
+19
View File
@@ -0,0 +1,19 @@
// Cutout definitions from project.json "cutouts" section.
// A cutout is a named viewport region (talkinghead, square, fullscreen, fullscreen2).
// x/y/width/height are stored as strings because gnommo uses both "%" and raw values.
exports.up = (pgm) => {
pgm.createTable('cutouts', {
id: 'id',
project_id: { type: 'integer', notNull: true, references: 'videos', onDelete: 'CASCADE' },
name: { type: 'varchar(50)', notNull: true }, // talkinghead | square | fullscreen | fullscreen2
x: { type: 'varchar(20)' }, // e.g. "-30%" or "0%"
y: { type: 'varchar(20)' },
width: { type: 'varchar(20)' }, // optional — height alone can define aspect
height: { type: 'varchar(20)' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.addConstraint('cutouts', 'cutouts_project_name_unique', 'UNIQUE (project_id, name)');
};
exports.down = (pgm) => { pgm.dropTable('cutouts'); };
@@ -0,0 +1,20 @@
// Per-project processing filter chains from project.json "default_filters".
// Each row is one filter step within a named target pipeline (audioonly, talkinghead, etc.).
// Filter-specific parameters (LUFS targets, chroma key colours, mask boundaries) live in params (jsonb).
exports.up = (pgm) => {
pgm.createTable('filter_chains', {
id: 'id',
project_id: { type: 'integer', notNull: true, references: 'videos', onDelete: 'CASCADE' },
target: { type: 'varchar(50)', notNull: true }, // audioonly | talkinghead
sort_order: { type: 'integer', notNull: true },
filter_type: { type: 'varchar(50)', notNull: true }, // audio_normalize | color_grade | gnommokey | mask
params: { type: 'jsonb' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('filter_chains', ['project_id', 'target', 'sort_order'], {
name: 'idx_filter_chains_project_target_order',
});
};
exports.down = (pgm) => { pgm.dropTable('filter_chains'); };
@@ -0,0 +1,32 @@
// Per-project video asset library from media/videos/videos.json.
// Includes both local clips and references to shared assets (is_shared=true).
// minio_object_key is null today; populated when assets migrate to MinIO.
exports.up = (pgm) => {
pgm.createTable('video_assets', {
id: 'id',
project_id: { type: 'integer', notNull: true, references: 'videos', onDelete: 'CASCADE' },
asset_key: { type: 'varchar(200)', notNull: true }, // key in videos.json
description: { type: 'text' },
source_file: { type: 'varchar(500)' }, // original local path
output_file: { type: 'varchar(500)' }, // rendered output path
minio_object_key: { type: 'varchar(500)' }, // future MinIO key
is_shared: { type: 'boolean', notNull: true, default: false },
cutout_name: { type: 'varchar(50)' }, // talkinghead | square | fullscreen
duration_seconds: { type: 'numeric' },
has_audio: { type: 'boolean', notNull: true, default: false },
volume: { type: 'numeric', default: 1.0 },
always_visible: { type: 'boolean', notNull: true, default: false },
pause_narration_seconds: { type: 'numeric' },
skip_seconds: { type: 'numeric' },
take_seconds: { type: 'numeric' },
filters: { type: 'jsonb' }, // per-asset filter overrides
raw_json: { type: 'jsonb' }, // full original entry
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.addConstraint('video_assets', 'video_assets_project_key_unique', 'UNIQUE (project_id, asset_key)');
pgm.createIndex('video_assets', 'project_id', { name: 'idx_video_assets_project_id' });
pgm.createIndex('video_assets', 'is_shared', { name: 'idx_video_assets_is_shared' });
};
exports.down = (pgm) => { pgm.dropTable('video_assets'); };
@@ -0,0 +1,25 @@
// Narration recording segments from media/narration/narration.json.
// Each segment is a recorded take with skip/take edit points.
// minio_object_key is null today; populated when recordings migrate to MinIO.
exports.up = (pgm) => {
pgm.createTable('narration_segments', {
id: 'id',
project_id: { type: 'integer', notNull: true, references: 'videos', onDelete: 'CASCADE' },
segment_key: { type: 'varchar(50)', notNull: true }, // Segment001, Segment002 ...
source_file: { type: 'varchar(500)' },
minio_object_key: { type: 'varchar(500)' },
use_audio_channels: { type: 'varchar(20)', default: 'auto' },
defer_loudnorm: { type: 'boolean', notNull: true, default: false },
skip_seconds: { type: 'numeric' },
take_seconds: { type: 'numeric' },
sort_order: { type: 'integer', notNull: true, default: 0 },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.addConstraint('narration_segments', 'narration_segments_project_key_unique',
'UNIQUE (project_id, segment_key)');
pgm.createIndex('narration_segments', ['project_id', 'sort_order'],
{ name: 'idx_narration_segments_order' });
};
exports.down = (pgm) => { pgm.dropTable('narration_segments'); };
@@ -0,0 +1,22 @@
// Background audio tracks from media/audio/audio.json.
// minio_object_key is null today; populated when audio files migrate to MinIO.
exports.up = (pgm) => {
pgm.createTable('audio_tracks', {
id: 'id',
project_id: { type: 'integer', notNull: true, references: 'videos', onDelete: 'CASCADE' },
track_key: { type: 'varchar(100)', notNull: true }, // cosmicpad, etc.
source_file: { type: 'varchar(500)' },
minio_object_key: { type: 'varchar(500)' },
volume: { type: 'numeric', notNull: true, default: 1.0 },
loop: { type: 'boolean', notNull: true, default: false },
overlap: { type: 'varchar(20)' }, // "15s"
ignore_pauses: { type: 'boolean', notNull: true, default: false },
duration_seconds: { type: 'numeric' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.addConstraint('audio_tracks', 'audio_tracks_project_key_unique',
'UNIQUE (project_id, track_key)');
};
exports.down = (pgm) => { pgm.dropTable('audio_tracks'); };
@@ -0,0 +1,23 @@
// Global shared asset library from shared_assets/ directory (shared_assets/videos.json,
// plus square/, landscape/, pexels/, models/ subfolders).
// This is the future MinIO object catalogue — minio_object_key is null until migration.
exports.up = (pgm) => {
pgm.createTable('shared_assets', {
id: 'id',
asset_key: { type: 'varchar(200)', notNull: true, unique: true },
asset_type: { type: 'varchar(20)', notNull: true }, // video | image | audio | model
subfolder: { type: 'varchar(100)' }, // pexels | square | landscape | models
filename: { type: 'varchar(500)', notNull: true },
minio_object_key: { type: 'varchar(500)' },
description: { type: 'text' },
duration_seconds: { type: 'numeric' },
has_audio: { type: 'boolean', notNull: true, default: false },
file_size_bytes: { type: 'bigint' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('shared_assets', 'asset_type', { name: 'idx_shared_assets_type' });
pgm.createIndex('shared_assets', 'subfolder', { name: 'idx_shared_assets_subfolder' });
};
exports.down = (pgm) => { pgm.dropTable('shared_assets'); };
@@ -0,0 +1,13 @@
exports.up = (pgm) => {
pgm.createTable('citations', {
id: 'id',
project_id: { type: 'integer', notNull: true, references: 'videos', onDelete: 'CASCADE' },
reference: { type: 'text', notNull: true },
context: { type: 'text' },
sort_order: { type: 'integer', notNull: true, default: 0 },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('citations', 'project_id', { name: 'idx_citations_project_id' });
};
exports.down = (pgm) => { pgm.dropTable('citations'); };
@@ -0,0 +1,15 @@
// Sync metadata from .gnommo_sync.json — tracks push/pull history with gnommo tool.
exports.up = (pgm) => {
pgm.createTable('project_sync', {
id: 'id',
project_id: { type: 'integer', notNull: true, unique: true,
references: 'videos', onDelete: 'CASCADE' },
last_pulled_at: { type: 'timestamptz' },
server_updated_at: { type: 'timestamptz' },
last_pushed_at: { type: 'timestamptz' },
last_handoff_at: { type: 'timestamptz' },
video_version: { type: 'integer', notNull: true, default: 0 },
});
};
exports.down = (pgm) => { pgm.dropTable('project_sync'); };
+14
View File
@@ -0,0 +1,14 @@
// Add richer slide spec fields used in detailed per-slide JSON (e.g. video5 spec/ directory).
exports.up = (pgm) => {
pgm.addColumns('slides', {
illustration_type: { type: 'varchar(50)' }, // glitch | transition | etc.
slide_target: { type: 'varchar(50)' }, // image | svg
symbols: { type: 'jsonb' }, // ["noise cloud", ...]
caption: { type: 'text' },
prompt: { type: 'text' }, // AI illustration prompt
});
};
exports.down = (pgm) => {
pgm.dropColumns('slides', ['illustration_type', 'slide_target', 'symbols', 'caption', 'prompt']);
};
@@ -0,0 +1,16 @@
// Word-level speech-to-text transcripts from narration_combined.transcript.json.
// Stored as a jsonb array [{word, start, end}] per source to avoid millions of tiny rows.
exports.up = (pgm) => {
pgm.createTable('transcripts', {
id: 'id',
project_id: { type: 'integer', notNull: true, references: 'videos', onDelete: 'CASCADE' },
source_name: { type: 'varchar(100)', notNull: true }, // narration_combined | talkinghead
words: { type: 'jsonb', notNull: true }, // [{word, start, end}, ...]
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.addConstraint('transcripts', 'transcripts_project_source_unique',
'UNIQUE (project_id, source_name)');
};
exports.down = (pgm) => { pgm.dropTable('transcripts'); };
@@ -0,0 +1,13 @@
// Add dimension and MIME metadata captured at upload time
exports.up = (pgm) => {
pgm.addColumns('shared_assets', {
width: { type: 'integer' },
height: { type: 'integer' },
mime_type: { type: 'varchar(100)' },
original_filename: { type: 'varchar(500)' },
});
};
exports.down = (pgm) => {
pgm.dropColumns('shared_assets', ['width', 'height', 'mime_type', 'original_filename']);
};
+66
View File
@@ -0,0 +1,66 @@
// Convert videos.id and slides.id (plus every FK that references them) from
// serial integers to UUID (gen_random_uuid).
//
// Safe for dev: truncates all video data first. Re-run the push script after
// migrating to re-ingest videos. shared_assets (asset library) is unchanged.
//
// This is a one-way migration. There is no meaningful down path.
const CHILD_TABLES = [
// table FK constraint name UNIQUE constraint name UNIQUE expression
['narration_segments', 'narration_segments_project_id_fkey', 'narration_segments_project_key_unique', '(project_id, segment_key)'],
['cutouts', 'cutouts_project_id_fkey', 'cutouts_project_name_unique', '(project_id, name)'],
['filter_chains', 'filter_chains_project_id_fkey', null, null],
['video_assets', 'video_assets_project_id_fkey', 'video_assets_project_key_unique', '(project_id, asset_key)'],
['audio_tracks', 'audio_tracks_project_id_fkey', 'audio_tracks_project_key_unique', '(project_id, track_key)'],
['citations', 'citations_project_id_fkey', null, null],
['project_sync', 'project_sync_project_id_fkey', 'project_sync_project_id_key', '(project_id)'],
['transcripts', 'transcripts_project_id_fkey', 'transcripts_project_source_unique', '(project_id, source_name)'],
];
exports.up = (pgm) => {
// ── Clear all video data ─────────────────────────────────────────────────
pgm.sql('TRUNCATE videos CASCADE');
// ── 1. Drop all FKs pointing at videos.id BEFORE touching the PK ────────
// slides
pgm.sql('ALTER TABLE slides DROP CONSTRAINT IF EXISTS slides_video_id_fkey');
pgm.sql('ALTER TABLE slides DROP CONSTRAINT IF EXISTS slides_video_id_gnommo_slide_id_unique');
// all other child tables
for (const [table, fkName, uniqueName] of CHILD_TABLES) {
pgm.sql(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${fkName}`);
if (uniqueName) pgm.sql(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${uniqueName}`);
}
// ── 2. videos.id: serial → uuid ─────────────────────────────────────────
pgm.sql('ALTER TABLE videos ALTER COLUMN id DROP DEFAULT');
pgm.sql('ALTER TABLE videos DROP CONSTRAINT videos_pkey');
pgm.sql('ALTER TABLE videos ALTER COLUMN id TYPE uuid USING gen_random_uuid()');
pgm.sql('ALTER TABLE videos ALTER COLUMN id SET DEFAULT gen_random_uuid()');
pgm.sql('ALTER TABLE videos ADD PRIMARY KEY (id)');
// ── 3. slides: video_id FK column, then id PK ───────────────────────────
pgm.sql('ALTER TABLE slides ALTER COLUMN video_id TYPE uuid USING gen_random_uuid()');
pgm.sql('ALTER TABLE slides ADD CONSTRAINT slides_video_id_gnommo_slide_id_unique UNIQUE (video_id, gnommo_slide_id)');
pgm.sql('ALTER TABLE slides ADD CONSTRAINT slides_video_id_fkey FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE');
pgm.sql('ALTER TABLE slides ALTER COLUMN id DROP DEFAULT');
pgm.sql('ALTER TABLE slides DROP CONSTRAINT slides_pkey');
pgm.sql('ALTER TABLE slides ALTER COLUMN id TYPE uuid USING gen_random_uuid()');
pgm.sql('ALTER TABLE slides ALTER COLUMN id SET DEFAULT gen_random_uuid()');
pgm.sql('ALTER TABLE slides ADD PRIMARY KEY (id)');
// ── 4. All other child tables: project_id integer → uuid ────────────────
for (const [table, fkName, uniqueName, uniqueExpr] of CHILD_TABLES) {
pgm.sql(`ALTER TABLE ${table} ALTER COLUMN project_id TYPE uuid USING gen_random_uuid()`);
if (uniqueName && uniqueExpr) {
pgm.sql(`ALTER TABLE ${table} ADD CONSTRAINT ${uniqueName} UNIQUE ${uniqueExpr}`);
}
pgm.sql(`ALTER TABLE ${table} ADD CONSTRAINT ${fkName} FOREIGN KEY (project_id) REFERENCES videos(id) ON DELETE CASCADE`);
}
};
exports.down = () => {
// Intentionally empty — reversing uuid → serial would require re-sequencing
// all IDs and is not worth implementing for a dev-only migration.
};
@@ -0,0 +1,20 @@
// Allows an asset to declare that it is a processed derivative of another asset.
// Example lineage: raw .mov → chroma-keyed .mp4 (parent_id → raw asset)
// A raw asset has parent_id = NULL.
// Multiple processed versions can share the same parent_id.
exports.up = (pgm) => {
pgm.addColumn('shared_assets', {
parent_id: {
type: 'integer',
references: 'shared_assets',
onDelete: 'SET NULL',
},
});
pgm.createIndex('shared_assets', 'parent_id', { name: 'idx_shared_assets_parent' });
};
exports.down = (pgm) => {
pgm.dropIndex('shared_assets', 'parent_id', { name: 'idx_shared_assets_parent' });
pgm.dropColumn('shared_assets', 'parent_id');
};
@@ -0,0 +1,34 @@
// Two additions:
//
// 1. asset_id — direct FK to shared_assets, preferred over minio_object_key
// for segments created through the editor UI. Gnommo-ingested segments
// continue to use minio_object_key; asset_id is NULL for those.
//
// 2. segment_type — 'raw' for original recordings, 'final' for the
// processed/transcoded versions that the player actually consumes.
// The GnommoPlayer export uses the 'final' list; 'raw' is for reference
// and source-of-truth tracking.
exports.up = (pgm) => {
pgm.addColumns('narration_segments', {
asset_id: {
type: 'integer',
references: 'shared_assets',
onDelete: 'SET NULL',
},
segment_type: {
type: 'varchar(10)',
notNull: true,
default: "'raw'",
},
});
pgm.createIndex('narration_segments', ['project_id', 'segment_type', 'sort_order'], {
name: 'idx_narration_segments_type_order',
});
};
exports.down = (pgm) => {
pgm.dropIndex('narration_segments', null, { name: 'idx_narration_segments_type_order' });
pgm.dropColumns('narration_segments', ['asset_id', 'segment_type']);
};
@@ -0,0 +1,22 @@
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE slide_candidates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slide_id uuid NOT NULL REFERENCES slides ON DELETE CASCADE,
component_key varchar(100) NOT NULL,
display_mode varchar(50),
props jsonb,
score numeric NOT NULL DEFAULT 0,
proposed_by varchar(200) NOT NULL,
proposed_by_name varchar(200),
created_at timestamptz DEFAULT NOW(),
updated_at timestamptz DEFAULT NOW(),
UNIQUE(slide_id, proposed_by)
)
`);
pgm.sql('CREATE INDEX idx_slide_candidates_slide_id ON slide_candidates(slide_id)');
};
exports.down = (pgm) => {
pgm.dropTable('slide_candidates');
};
@@ -0,0 +1,15 @@
// Attribution metadata for the shared asset library.
// Covers provenance (where it came from), legal (license, rights holder),
// and internal tracking (who uploaded it).
exports.up = (pgm) => {
pgm.addColumns('shared_assets', {
license: { type: 'varchar(200)' }, // e.g. "Pexels License", "CC BY 4.0", "CC0 1.0"
copyright_holder: { type: 'varchar(500)' }, // photographer / creator name
source_url: { type: 'varchar(1000)' }, // page URL where asset was obtained
uploader: { type: 'varchar(200)' }, // who added it to this system
});
};
exports.down = (pgm) => {
pgm.dropColumns('shared_assets', ['license', 'copyright_holder', 'source_url', 'uploader']);
};
+3258
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "gnommoeditor-backend",
"version": "1.0.0",
"description": "Backend API for GnommoEditor",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"migrate": "node run-migrations.cjs",
"migrate:up": "node run-migrations.cjs up",
"migrate:down": "node run-migrations.cjs down",
"migrate:create": "node-pg-migrate create"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.600.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.0.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3"
}
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
const path = require('path');
if (!process.env.DATABASE_URL) {
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
}
if (!process.env.DATABASE_URL) {
console.error('ERROR: DATABASE_URL is not set');
process.exit(1);
}
const { spawn } = require('child_process');
const args = process.argv.slice(2);
const migrateArgs = [
'node-pg-migrate',
'-f', path.resolve(__dirname, 'migrate-config.cjs'),
'--no-check-order',
...args
];
const migrate = spawn('npx', migrateArgs, {
stdio: 'inherit',
env: process.env
});
migrate.on('close', (code) => {
process.exit(code);
});
+67
View File
@@ -0,0 +1,67 @@
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import pg from 'pg';
import ingestRoutes from './routes/ingest.js';
import videosRoutes from './routes/videos.js';
import slidesRoutes from './routes/slides.js';
import assetsRoutes from './routes/assets.js';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
pool.query('SELECT NOW()')
.then(() => console.log('Database connected'))
.catch(err => console.error('Database connection error:', err));
const app = express();
const PORT = process.env.PORT || 3001;
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use(express.json({ limit: '10mb' }));
app.set('pool', pool);
app.use('/api/ingest', ingestRoutes);
app.use('/api/videos', videosRoutes);
app.use('/api/slides', slidesRoutes);
app.use('/api/assets', assetsRoutes);
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
} catch (err) {
res.status(503).json({ status: 'unhealthy', error: err.message });
}
});
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`GnommoEditor backend running on port ${PORT}`);
});
+38
View File
@@ -0,0 +1,38 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
// Backend connects to MinIO via MINIO_ENDPOINT (internal Docker: http://minio:9000).
// Public URLs use MINIO_PUBLIC_URL (browser-accessible: http://localhost:9000).
const ENDPOINT = process.env.MINIO_ENDPOINT || 'http://localhost:9000';
const PUBLIC_URL = process.env.MINIO_PUBLIC_URL || ENDPOINT;
const BUCKET = process.env.MINIO_BUCKET || 'glitch-university';
const client = new S3Client({
endpoint: ENDPOINT,
region: 'us-east-1',
credentials: {
accessKeyId: process.env.MINIO_ROOT_USER || 'minioadmin',
secretAccessKey: process.env.MINIO_ROOT_PASSWORD || 'minioadmin',
},
forcePathStyle: true, // required for MinIO
});
export function publicUrl(objectKey) {
return `${PUBLIC_URL}/${BUCKET}/${objectKey}`;
}
export async function uploadBuffer(objectKey, buffer, contentType) {
await client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: objectKey,
Body: buffer,
ContentType: contentType,
}));
return publicUrl(objectKey);
}
export async function deleteObject(objectKey) {
await client.send(new DeleteObjectCommand({
Bucket: BUCKET,
Key: objectKey,
}));
}
+21
View File
@@ -0,0 +1,21 @@
export function requireIngestKey(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'API key required. Use Authorization: Bearer <key>' });
}
const apiKey = authHeader.slice(7);
const validKey = process.env.INGEST_API_KEY;
if (!validKey) {
console.error('INGEST_API_KEY environment variable not set');
return res.status(500).json({ error: 'API key not configured on server' });
}
if (apiKey !== validKey) {
return res.status(403).json({ error: 'Invalid API key' });
}
next();
}
+27
View File
@@ -0,0 +1,27 @@
import jwt from 'jsonwebtoken';
export function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.slice(7);
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
export function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) return next();
const token = authHeader.slice(7);
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
} catch {
// invalid token — proceed unauthenticated
}
next();
}
+207
View File
@@ -0,0 +1,207 @@
import { Router } from 'express';
import multer from 'multer';
import { randomUUID } from 'crypto';
import { uploadBuffer, deleteObject, publicUrl } from '../lib/minio.js';
const router = Router();
// 500 MB limit — video files can be large
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500 * 1024 * 1024 },
});
function assetType(mimetype) {
if (mimetype.startsWith('image/')) return 'image';
if (mimetype.startsWith('video/')) return 'video';
if (mimetype.startsWith('audio/')) return 'audio';
return 'other';
}
function makeObjectKey(originalName, type) {
const ext = originalName.split('.').pop().toLowerCase();
const safe = originalName
.replace(/\.[^.]+$/, '') // strip extension
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 40);
return `assets/${type}/${Date.now()}-${randomUUID().slice(0, 8)}-${safe}.${ext}`;
}
// ── GET /api/assets ──────────────────────────────────────────────────────────
// ?type=image|video|audio|other optional filter
// ?q=searchterm optional filename search
router.get('/', async (req, res) => {
const pool = req.app.get('pool');
const { type, q } = req.query;
try {
let query = `
SELECT id, asset_key, asset_type, original_filename, filename,
minio_object_key, mime_type, width, height,
duration_seconds, file_size_bytes, description,
license, copyright_holder, source_url, uploader,
created_at
FROM shared_assets
WHERE 1=1
`;
const params = [];
if (type) {
params.push(type);
query += ` AND asset_type = $${params.length}`;
}
if (q) {
params.push(`%${q}%`);
query += ` AND (original_filename ILIKE $${params.length}
OR description ILIKE $${params.length}
OR copyright_holder ILIKE $${params.length}
OR source_url ILIKE $${params.length})`;
}
query += ' ORDER BY created_at DESC';
const result = await pool.query(query, params);
// Attach public URL to each row
const assets = result.rows.map(row => ({
...row,
url: row.minio_object_key ? publicUrl(row.minio_object_key) : null,
}));
res.json({ assets });
} catch (err) {
console.error('List assets error:', err);
res.status(500).json({ error: 'Failed to list assets' });
}
});
// ── POST /api/assets/upload ──────────────────────────────────────────────────
// Multipart form fields:
// file — the file
// width — integer (from client-side inspection)
// height — integer
// duration — float (seconds)
// description — optional string
router.post('/upload', upload.single('file'), async (req, res) => {
const pool = req.app.get('pool');
if (!req.file) return res.status(400).json({ error: 'No file provided' });
const { originalname, mimetype, buffer, size } = req.file;
const width = req.body.width ? parseInt(req.body.width, 10) : null;
const height = req.body.height ? parseInt(req.body.height, 10) : null;
const duration = req.body.duration ? parseFloat(req.body.duration) : null;
const description = req.body.description || null;
const license = req.body.license || null;
const copyright_holder = req.body.copyright_holder || null;
const source_url = req.body.source_url || null;
const uploader = req.body.uploader || null;
const type = assetType(mimetype);
const objectKey = makeObjectKey(originalname, type);
try {
const url = await uploadBuffer(objectKey, buffer, mimetype);
const result = await pool.query(
`INSERT INTO shared_assets (
asset_key, asset_type, filename, original_filename,
minio_object_key, mime_type,
width, height, duration_seconds, file_size_bytes,
description, has_audio,
license, copyright_holder, source_url, uploader,
updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
RETURNING *`,
[
objectKey,
type,
originalname,
originalname,
objectKey,
mimetype,
width,
height,
duration,
size,
description,
type === 'video' || type === 'audio',
license,
copyright_holder,
source_url,
uploader,
]
);
res.status(201).json({
asset: { ...result.rows[0], url },
});
} catch (err) {
console.error('Upload error:', err);
res.status(500).json({ error: 'Upload failed', detail: err.message });
}
});
// ── GET /api/assets/:id ──────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const pool = req.app.get('pool');
try {
const result = await pool.query('SELECT * FROM shared_assets WHERE id = $1', [req.params.id]);
if (!result.rows.length) return res.status(404).json({ error: 'Not found' });
const row = result.rows[0];
res.json({ asset: { ...row, url: row.minio_object_key ? publicUrl(row.minio_object_key) : null } });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch asset' });
}
});
// ── PATCH /api/assets/:id ────────────────────────────────────────────────────
// Accepts any subset of: description, license, copyright_holder, source_url, uploader
// Pass empty string to clear a field (stored as null).
router.patch('/:id', async (req, res) => {
const pool = req.app.get('pool');
const ALLOWED = ['description', 'license', 'copyright_holder', 'source_url', 'uploader'];
const setClauses = [];
const values = [];
for (const key of ALLOWED) {
if (key in req.body) {
values.push(req.body[key] || null);
setClauses.push(`${key} = $${values.length}`);
}
}
if (setClauses.length === 0) return res.status(400).json({ error: 'No recognised fields to update' });
values.push(req.params.id);
try {
const result = await pool.query(
`UPDATE shared_assets SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE id = $${values.length} RETURNING *`,
values
);
if (!result.rows.length) return res.status(404).json({ error: 'Not found' });
const row = result.rows[0];
res.json({ asset: { ...row, url: row.minio_object_key ? publicUrl(row.minio_object_key) : null } });
} catch (err) {
res.status(500).json({ error: 'Failed to update asset' });
}
});
// ── DELETE /api/assets/:id ───────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const pool = req.app.get('pool');
try {
const result = await pool.query(
'DELETE FROM shared_assets WHERE id = $1 RETURNING minio_object_key',
[req.params.id]
);
if (!result.rows.length) return res.status(404).json({ error: 'Not found' });
const { minio_object_key } = result.rows[0];
if (minio_object_key) {
await deleteObject(minio_object_key).catch(err =>
console.warn('MinIO delete failed (continuing):', err.message)
);
}
res.json({ success: true });
} catch (err) {
console.error('Delete asset error:', err);
res.status(500).json({ error: 'Failed to delete asset' });
}
});
export default router;
+161
View File
@@ -0,0 +1,161 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/jwtAuth.js';
const router = Router({ mergeParams: true });
// GET /api/slides/:slideId/candidates
router.get('/', async (req, res) => {
const pool = req.app.get('pool');
const { slideId } = req.params;
try {
const result = await pool.query(
'SELECT * FROM slide_candidates WHERE slide_id = $1 ORDER BY created_at',
[slideId]
);
res.json({ candidates: result.rows });
} catch (err) {
console.error('Get candidates error:', err);
res.status(500).json({ error: 'Failed to fetch candidates' });
}
});
// POST /api/slides/:slideId/candidates
router.post('/', requireAuth, async (req, res) => {
const pool = req.app.get('pool');
const { slideId } = req.params;
const { component_key, display_mode, props } = req.body;
if (!component_key) return res.status(400).json({ error: 'component_key is required' });
const proposed_by = req.user.sub;
const proposed_by_name = req.user.name || req.user.email || req.user.sub;
try {
const result = await pool.query(
`INSERT INTO slide_candidates
(slide_id, component_key, display_mode, props, proposed_by, proposed_by_name)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
slideId, component_key, display_mode || null,
props != null ? JSON.stringify(props) : null,
proposed_by, proposed_by_name,
]
);
res.status(201).json({ candidate: result.rows[0] });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'You already have a proposal for this slide' });
if (err.code === '23503') return res.status(404).json({ error: 'Slide not found' });
console.error('Create candidate error:', err);
res.status(500).json({ error: 'Failed to create candidate' });
}
});
// PUT /api/slides/:slideId/candidates/:candidateId
router.put('/:candidateId', requireAuth, async (req, res) => {
const pool = req.app.get('pool');
const { slideId, candidateId } = req.params;
const { component_key, display_mode, props } = req.body;
try {
const existing = await pool.query(
'SELECT * FROM slide_candidates WHERE id = $1 AND slide_id = $2',
[candidateId, slideId]
);
if (existing.rows.length === 0) return res.status(404).json({ error: 'Candidate not found' });
const c = existing.rows[0];
const isOwner = c.proposed_by === req.user.sub;
const isEditor = ['editor', 'admin'].includes(req.user.role);
if (!isOwner && !isEditor) return res.status(403).json({ error: 'Forbidden' });
const result = await pool.query(
`UPDATE slide_candidates SET
component_key = COALESCE($1, component_key),
display_mode = COALESCE($2, display_mode),
props = COALESCE($3, props),
updated_at = NOW()
WHERE id = $4 RETURNING *`,
[
component_key || null,
display_mode || null,
props != null ? JSON.stringify(props) : null,
candidateId,
]
);
res.json({ candidate: result.rows[0] });
} catch (err) {
console.error('Update candidate error:', err);
res.status(500).json({ error: 'Failed to update candidate' });
}
});
// DELETE /api/slides/:slideId/candidates/:candidateId
router.delete('/:candidateId', requireAuth, async (req, res) => {
const pool = req.app.get('pool');
const { slideId, candidateId } = req.params;
try {
const existing = await pool.query(
'SELECT * FROM slide_candidates WHERE id = $1 AND slide_id = $2',
[candidateId, slideId]
);
if (existing.rows.length === 0) return res.status(404).json({ error: 'Candidate not found' });
const c = existing.rows[0];
const isOwner = c.proposed_by === req.user.sub;
const isEditor = ['editor', 'admin'].includes(req.user.role);
if (!isOwner && !isEditor) return res.status(403).json({ error: 'Forbidden' });
await pool.query('DELETE FROM slide_candidates WHERE id = $1', [candidateId]);
res.json({ success: true });
} catch (err) {
console.error('Delete candidate error:', err);
res.status(500).json({ error: 'Failed to delete candidate' });
}
});
// POST /api/slides/:slideId/candidates/:candidateId/promote
router.post('/:candidateId/promote', requireAuth, async (req, res) => {
const pool = req.app.get('pool');
const { slideId, candidateId } = req.params;
const isEditor = ['editor', 'admin'].includes(req.user.role);
if (!isEditor) return res.status(403).json({ error: 'Editor or admin role required' });
const client = await pool.connect();
try {
const existing = await client.query(
'SELECT * FROM slide_candidates WHERE id = $1 AND slide_id = $2',
[candidateId, slideId]
);
if (existing.rows.length === 0) return res.status(404).json({ error: 'Candidate not found' });
const c = existing.rows[0];
await client.query('BEGIN');
const slideResult = await client.query(
`UPDATE slides SET
component_key = $1,
display_mode = COALESCE($2, display_mode),
props = COALESCE($3, props),
updated_at = NOW()
WHERE id = $4 RETURNING *`,
[c.component_key, c.display_mode, c.props, slideId]
);
await client.query('DELETE FROM slide_candidates WHERE id = $1', [candidateId]);
await client.query('COMMIT');
res.json({ slide: slideResult.rows[0] });
} catch (err) {
await client.query('ROLLBACK');
console.error('Promote candidate error:', err);
res.status(500).json({ error: 'Failed to promote candidate' });
} finally {
client.release();
}
});
export default router;
+322
View File
@@ -0,0 +1,322 @@
import { Router } from 'express';
import { requireIngestKey } from '../middleware/auth.js';
const router = Router();
// Parse manuscript text into a map of slide_id -> presenter notes.
function parseManuscript(text) {
const notes = {};
if (!text) return notes;
const blocks = text.split(/\[S(\d+)\]/);
for (let i = 1; i < blocks.length; i += 2) {
const slideNum = blocks[i];
const content = blocks[i + 1]?.trim() ?? '';
notes[`S${slideNum}`] = content;
}
return notes;
}
// Extract numeric sort key from "Segment001" -> 1
function segmentSortOrder(key) {
const m = key.match(/(\d+)$/);
return m ? parseInt(m[1], 10) : 0;
}
// POST /api/ingest
router.post('/', requireIngestKey, async (req, res) => {
const pool = req.app.get('pool');
const {
project,
slides,
manuscript,
narration,
audio,
videos: videoAssets,
citations,
sync,
} = req.body;
if (!project) return res.status(400).json({ error: 'project is required' });
if (!project.id) return res.status(400).json({ error: 'project.id is required' });
const presenterNotes = parseManuscript(manuscript);
const client = await pool.connect();
try {
await client.query('BEGIN');
// ── 1. Upsert video ────────────────────────────────────────────────────
const res_v = await client.query(
`INSERT INTO videos (
gnommo_id, course_code, title, description, hook, status,
resolution_width, resolution_height, fps,
default_slide_type, background, platform_targets,
duration_seconds, youtube_url,
output_video, keynote_file, parent_gnommo_id, outro, shorts, manuscript_path,
raw_project_json, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,NOW())
ON CONFLICT (gnommo_id) DO UPDATE SET
course_code = EXCLUDED.course_code,
title = EXCLUDED.title,
description = EXCLUDED.description,
hook = EXCLUDED.hook,
status = EXCLUDED.status,
resolution_width = EXCLUDED.resolution_width,
resolution_height = EXCLUDED.resolution_height,
fps = EXCLUDED.fps,
default_slide_type = EXCLUDED.default_slide_type,
background = EXCLUDED.background,
platform_targets = EXCLUDED.platform_targets,
duration_seconds = EXCLUDED.duration_seconds,
youtube_url = EXCLUDED.youtube_url,
output_video = EXCLUDED.output_video,
keynote_file = EXCLUDED.keynote_file,
parent_gnommo_id = EXCLUDED.parent_gnommo_id,
outro = EXCLUDED.outro,
shorts = EXCLUDED.shorts,
manuscript_path = EXCLUDED.manuscript_path,
raw_project_json = EXCLUDED.raw_project_json,
updated_at = NOW()
RETURNING id`,
[
project.id,
project.coursecode ?? project.course_code ?? null,
project.name ?? project.title ?? project.id,
project.description ?? null,
project.hook ?? null,
project.status ?? null,
project.resolution?.[0] ?? project.resolution_width ?? null,
project.resolution?.[1] ?? project.resolution_height ?? null,
project.fps ?? null,
project.defaultSlideType ?? project.default_slide_type ?? null,
project.background ?? null,
project.platform_targets ?? null,
project.duration_seconds ?? null,
project.youtube_url ?? null,
project.output_video ?? null,
project.keynote_file ?? null,
project.parent_project ?? null,
project.outro ?? null,
project.shorts ?? null,
project.manuscript ?? null,
JSON.stringify(project),
]
);
const videoId = res_v.rows[0].id;
// ── 2. Upsert slides ───────────────────────────────────────────────────
let slidesUpserted = 0;
if (slides) {
for (const [slideId, data] of Object.entries(slides)) {
const orderMatch = slideId.match(/^S(\d+)$/i);
const slideOrder = orderMatch ? parseInt(orderMatch[1], 10) : 0;
await client.query(
`INSERT INTO slides (
video_id, gnommo_slide_id, slide_order,
image_filename, display_mode, presenter_notes, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (video_id, gnommo_slide_id) DO UPDATE SET
slide_order = EXCLUDED.slide_order,
image_filename = EXCLUDED.image_filename,
display_mode = EXCLUDED.display_mode,
presenter_notes = EXCLUDED.presenter_notes,
updated_at = NOW()`,
[videoId, slideId, slideOrder, data.image ?? null, data.type ?? null,
presenterNotes[slideId] ?? null]
);
slidesUpserted++;
}
}
// ── 3. Upsert cutouts ──────────────────────────────────────────────────
if (project.cutouts) {
for (const [name, c] of Object.entries(project.cutouts)) {
await client.query(
`INSERT INTO cutouts (project_id, name, x, y, width, height, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (project_id, name) DO UPDATE SET
x = EXCLUDED.x, y = EXCLUDED.y,
width = EXCLUDED.width, height = EXCLUDED.height,
updated_at = NOW()`,
[videoId, name, c.x ?? null, c.y ?? null, c.width ?? null, c.height ?? null]
);
}
}
// ── 4. Replace filter chains ───────────────────────────────────────────
if (project.default_filters) {
await client.query('DELETE FROM filter_chains WHERE project_id = $1', [videoId]);
for (const [target, filters] of Object.entries(project.default_filters)) {
for (let i = 0; i < filters.length; i++) {
const f = filters[i];
const { type: filterType, ...params } = f;
await client.query(
`INSERT INTO filter_chains (project_id, target, sort_order, filter_type, params, updated_at)
VALUES ($1,$2,$3,$4,$5,NOW())`,
[videoId, target, i, filterType, JSON.stringify(params)]
);
}
}
}
// ── 5. Upsert video assets ─────────────────────────────────────────────
if (videoAssets) {
for (const [key, v] of Object.entries(videoAssets)) {
await client.query(
`INSERT INTO video_assets (
project_id, asset_key, description, source_file, output_file,
is_shared, cutout_name, duration_seconds, has_audio, volume,
always_visible, pause_narration_seconds, skip_seconds, take_seconds,
filters, raw_json, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
ON CONFLICT (project_id, asset_key) DO UPDATE SET
description = EXCLUDED.description,
source_file = EXCLUDED.source_file,
output_file = EXCLUDED.output_file,
is_shared = EXCLUDED.is_shared,
cutout_name = EXCLUDED.cutout_name,
duration_seconds = EXCLUDED.duration_seconds,
has_audio = EXCLUDED.has_audio,
volume = EXCLUDED.volume,
always_visible = EXCLUDED.always_visible,
pause_narration_seconds = EXCLUDED.pause_narration_seconds,
skip_seconds = EXCLUDED.skip_seconds,
take_seconds = EXCLUDED.take_seconds,
filters = EXCLUDED.filters,
raw_json = EXCLUDED.raw_json,
updated_at = NOW()`,
[
videoId, key,
v.description ?? null,
v.source_file ?? null,
v.output_file ?? null,
v.is_shared ?? false,
v.cutout ?? null,
v.duration ?? null,
v.has_audio ?? false,
v.volume ?? 1.0,
v.always_visible ?? false,
v.pause_narration ?? null,
v.skip ?? null,
v.take ?? null,
v.filter != null ? JSON.stringify(v.filter) : null,
JSON.stringify(v),
]
);
}
}
// ── 6. Upsert narration segments ───────────────────────────────────────
if (narration) {
for (const [key, seg] of Object.entries(narration)) {
await client.query(
`INSERT INTO narration_segments (
project_id, segment_key, source_file,
use_audio_channels, defer_loudnorm,
skip_seconds, take_seconds, sort_order, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (project_id, segment_key) DO UPDATE SET
source_file = EXCLUDED.source_file,
use_audio_channels = EXCLUDED.use_audio_channels,
defer_loudnorm = EXCLUDED.defer_loudnorm,
skip_seconds = EXCLUDED.skip_seconds,
take_seconds = EXCLUDED.take_seconds,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()`,
[
videoId, key,
seg.source_file ?? null,
seg.use_audio_channels ?? 'auto',
seg.defer_loudnorm ?? false,
seg.skip ?? null,
seg.take ?? null,
segmentSortOrder(key),
]
);
}
}
// ── 7. Upsert audio tracks ─────────────────────────────────────────────
if (audio) {
for (const [key, track] of Object.entries(audio)) {
await client.query(
`INSERT INTO audio_tracks (
project_id, track_key, source_file,
volume, loop, overlap, ignore_pauses, duration_seconds, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (project_id, track_key) DO UPDATE SET
source_file = EXCLUDED.source_file,
volume = EXCLUDED.volume,
loop = EXCLUDED.loop,
overlap = EXCLUDED.overlap,
ignore_pauses = EXCLUDED.ignore_pauses,
duration_seconds = EXCLUDED.duration_seconds,
updated_at = NOW()`,
[
videoId, key,
track.file ?? null,
track.volume ?? 1.0,
track.loop ?? false,
track.overlap ?? null,
track.ignore_pauses ?? false,
track.duration ?? null,
]
);
}
}
// ── 8. Replace citations (no stable key — delete + re-insert) ──────────
if (citations) {
await client.query('DELETE FROM citations WHERE project_id = $1', [videoId]);
for (let i = 0; i < citations.length; i++) {
const c = citations[i];
await client.query(
`INSERT INTO citations (project_id, reference, context, sort_order)
VALUES ($1,$2,$3,$4)`,
[videoId, c.reference ?? '', c.context ?? null, i]
);
}
}
// ── 9. Upsert project sync metadata ───────────────────────────────────
if (sync) {
await client.query(
`INSERT INTO project_sync (
project_id, last_pulled_at, server_updated_at,
last_pushed_at, last_handoff_at, video_version
) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (project_id) DO UPDATE SET
last_pulled_at = EXCLUDED.last_pulled_at,
server_updated_at = EXCLUDED.server_updated_at,
last_pushed_at = EXCLUDED.last_pushed_at,
last_handoff_at = EXCLUDED.last_handoff_at,
video_version = EXCLUDED.video_version`,
[
videoId,
sync.last_pulled_at ?? null,
sync.server_updated_at ?? null,
sync.last_pushed_at ?? null,
sync.last_handoff_at ?? null,
sync.video_version ?? 0,
]
);
}
await client.query('COMMIT');
res.json({
success: true,
video_id: videoId,
slides_upserted: slidesUpserted,
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Ingest error:', err);
res.status(500).json({ error: 'Ingest failed', detail: err.message });
} finally {
client.release();
}
});
export default router;
+70
View File
@@ -0,0 +1,70 @@
import { Router } from 'express';
import candidatesRouter from './candidates.js';
const router = Router();
// GET /api/slides/:id
router.get('/:id', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
try {
const result = await pool.query('SELECT * FROM slides WHERE id = $1', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Slide not found' });
}
res.json({ slide: result.rows[0] });
} catch (err) {
console.error('Get slide error:', err);
res.status(500).json({ error: 'Failed to fetch slide' });
}
});
// PUT /api/slides/:id — update timing, component_key, props, presenter_notes, display_mode
router.put('/:id', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
const { start_time_sec, end_time_sec, component_key, props, presenter_notes, display_mode } = req.body;
try {
const result = await pool.query(
`UPDATE slides SET
start_time_sec = COALESCE($1, start_time_sec),
end_time_sec = COALESCE($2, end_time_sec),
component_key = COALESCE($3, component_key),
props = COALESCE($4, props),
presenter_notes = COALESCE($5, presenter_notes),
display_mode = COALESCE($6, display_mode),
updated_at = NOW()
WHERE id = $7 RETURNING *`,
[start_time_sec, end_time_sec, component_key,
props ? JSON.stringify(props) : null,
presenter_notes, display_mode, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Slide not found' });
}
res.json({ slide: result.rows[0] });
} catch (err) {
console.error('Update slide error:', err);
res.status(500).json({ error: 'Failed to update slide' });
}
});
// DELETE /api/slides/:id
router.delete('/:id', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
try {
const result = await pool.query('DELETE FROM slides WHERE id = $1 RETURNING id', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Slide not found' });
}
res.json({ success: true });
} catch (err) {
console.error('Delete slide error:', err);
res.status(500).json({ error: 'Failed to delete slide' });
}
});
router.use('/:slideId/candidates', candidatesRouter);
export default router;
+519
View File
@@ -0,0 +1,519 @@
import { Router } from 'express';
import { publicUrl } from '../lib/minio.js';
const router = Router();
// POST /api/videos — create a new video
router.post('/', async (req, res) => {
const pool = req.app.get('pool');
const { title, gnommo_id, course_code, status = 'draft', description, hook } = req.body;
if (!title) return res.status(400).json({ error: 'title is required' });
const gnommoId = gnommo_id || `editor-${Date.now()}`;
try {
const result = await pool.query(
`INSERT INTO videos (gnommo_id, title, course_code, status, description, hook)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[gnommoId, title, course_code || null, status, description || null, hook || null]
);
res.status(201).json({ video: result.rows[0] });
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'A video with that gnommo_id already exists' });
}
console.error('Create video error:', err);
res.status(500).json({ error: 'Failed to create video' });
}
});
// GET /api/videos — list all videos with slide count
router.get('/', async (req, res) => {
const pool = req.app.get('pool');
try {
const result = await pool.query(`
SELECT v.id, v.gnommo_id, v.title, v.status, v.course_code,
COUNT(s.id)::int AS slide_count
FROM videos v
LEFT JOIN slides s ON s.video_id = v.id
GROUP BY v.id
ORDER BY v.id
`);
res.json({ videos: result.rows });
} catch (err) {
console.error('List videos error:', err);
res.status(500).json({ error: 'Failed to fetch videos' });
}
});
// GET /api/videos/:id — video metadata + slides + narration segments
router.get('/:id', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
try {
const videoResult = await pool.query('SELECT * FROM videos WHERE id = $1', [id]);
if (videoResult.rows.length === 0) {
return res.status(404).json({ error: 'Video not found' });
}
const slidesResult = await pool.query(
'SELECT * FROM slides WHERE video_id = $1 ORDER BY slide_order',
[id]
);
const segmentsResult = await pool.query(
`SELECT ns.*,
COALESCE(sa_id.original_filename, sa_key.original_filename) AS asset_name,
COALESCE(sa_id.duration_seconds, sa_key.duration_seconds) AS asset_duration,
sa_id.parent_id AS asset_parent_id
FROM narration_segments ns
LEFT JOIN shared_assets sa_id ON ns.asset_id IS NOT NULL AND sa_id.id = ns.asset_id
LEFT JOIN shared_assets sa_key ON ns.asset_id IS NULL AND sa_key.minio_object_key = ns.minio_object_key
WHERE ns.project_id = $1
ORDER BY ns.segment_type, ns.sort_order`,
[id]
);
const narrationSegments = segmentsResult.rows.map(seg => ({
...seg,
url: seg.minio_object_key ? publicUrl(seg.minio_object_key) : null,
}));
res.json({
video: videoResult.rows[0],
slides: slidesResult.rows,
narrationSegments,
});
} catch (err) {
console.error('Get video error:', err);
res.status(500).json({ error: 'Failed to fetch video' });
}
});
// PUT /api/videos/:id — update video metadata
router.put('/:id', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
const { title, description, hook, status, youtube_url, duration_seconds, course_code } = req.body;
try {
const result = await pool.query(
`UPDATE videos SET
title = COALESCE($1, title),
description = COALESCE($2, description),
hook = COALESCE($3, hook),
status = COALESCE($4, status),
youtube_url = COALESCE($5, youtube_url),
duration_seconds = COALESCE($6, duration_seconds),
course_code = COALESCE($7, course_code),
updated_at = NOW()
WHERE id = $8 RETURNING *`,
[title, description, hook, status, youtube_url, duration_seconds, course_code, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Video not found' });
}
res.json({ video: result.rows[0] });
} catch (err) {
console.error('Update video error:', err);
res.status(500).json({ error: 'Failed to update video' });
}
});
// DELETE /api/videos/:id
router.delete('/:id', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
try {
const result = await pool.query('DELETE FROM videos WHERE id = $1 RETURNING id', [id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Video not found' });
res.json({ success: true });
} catch (err) {
console.error('Delete video error:', err);
res.status(500).json({ error: 'Failed to delete video' });
}
});
// POST /api/videos/:id/slides — add a slide
router.post('/:id/slides', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
const {
component_key, props,
display_mode, presenter_notes,
// legacy / gnommo-ingest path
asset_id, image_filename,
} = req.body;
try {
// Determine next slide number (never reuse deleted numbers)
const orderResult = await pool.query(
`SELECT COALESCE(MAX(
CASE WHEN gnommo_slide_id ~ '^S[0-9]+$'
THEN CAST(SUBSTRING(gnommo_slide_id FROM 2) AS INTEGER)
ELSE 0 END
), 0) + 1 AS next_num
FROM slides WHERE video_id = $1`,
[id]
);
const slideNum = orderResult.rows[0].next_num;
const gnommoSlideId = `S${slideNum}`;
// Resolve display_mode: explicit > template default > fallback
const resolvedMode = display_mode || 'fullscreen';
// Legacy: resolve image filename from asset if provided
let filename = image_filename || null;
if (asset_id && !component_key) {
const assetResult = await pool.query(
'SELECT minio_object_key, original_filename FROM shared_assets WHERE id = $1',
[asset_id]
);
if (assetResult.rows.length > 0) {
const asset = assetResult.rows[0];
filename = asset.minio_object_key ? publicUrl(asset.minio_object_key) : asset.original_filename;
}
}
const result = await pool.query(
`INSERT INTO slides
(video_id, gnommo_slide_id, slide_order, image_filename,
display_mode, presenter_notes, component_key, props)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
id, gnommoSlideId, slideNum, filename,
resolvedMode, presenter_notes || null,
component_key || null,
props != null ? JSON.stringify(props) : null,
]
);
res.status(201).json({ slide: result.rows[0] });
} catch (err) {
console.error('Create slide error:', err);
res.status(500).json({ error: 'Failed to create slide' });
}
});
// POST /api/videos/:id/narration-segments
// Body: { asset_ids: [id,...], segment_type: 'raw'|'final' }
// or: { asset_id: id, segment_type } (single, backwards-compat)
// Assets are inserted in the order supplied; caller sorts by filename first.
router.post('/:id/narration-segments', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
const { asset_id, asset_ids, segment_type = 'raw', skip_seconds, take_seconds } = req.body;
const idsToAdd = asset_ids ?? (asset_id != null ? [asset_id] : []);
if (idsToAdd.length === 0) return res.status(400).json({ error: 'asset_id or asset_ids required' });
const keyPrefix = segment_type === 'final' ? 'Final' : 'Segment';
const client = await pool.connect();
try {
// Max sort_order for this type
const orderRow = await client.query(
`SELECT COALESCE(MAX(sort_order), -1) AS max_order
FROM narration_segments WHERE project_id = $1 AND segment_type = $2`,
[id, segment_type]
);
let nextOrder = orderRow.rows[0].max_order + 1;
// Max key number across all types (keys must be unique per project)
const keyRow = await client.query(
`SELECT COALESCE(MAX(
CAST(REGEXP_REPLACE(segment_key, '[^0-9]', '', 'g') AS INTEGER)
), 0) AS max_num
FROM narration_segments WHERE project_id = $1`,
[id]
);
let nextKeyNum = keyRow.rows[0].max_num + 1;
const assetsResult = await client.query(
'SELECT id, minio_object_key, original_filename FROM shared_assets WHERE id = ANY($1)',
[idsToAdd]
);
const assetMap = Object.fromEntries(assetsResult.rows.map(a => [String(a.id), a]));
const created = [];
await client.query('BEGIN');
for (const aid of idsToAdd) {
const asset = assetMap[String(aid)];
if (!asset) continue;
const segmentKey = `${keyPrefix}${String(nextKeyNum).padStart(3, '0')}`;
const row = await client.query(
`INSERT INTO narration_segments
(project_id, segment_key, segment_type, source_file,
minio_object_key, asset_id, sort_order, skip_seconds, take_seconds)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[id, segmentKey, segment_type, asset.original_filename,
asset.minio_object_key, asset.id, nextOrder,
skip_seconds ?? null, take_seconds ?? null]
);
created.push({
...row.rows[0],
asset_name: asset.original_filename,
url: asset.minio_object_key ? publicUrl(asset.minio_object_key) : null,
});
nextOrder++; nextKeyNum++;
}
await client.query('COMMIT');
res.status(201).json({ segments: created });
} catch (err) {
await client.query('ROLLBACK');
console.error('Create narration segments error:', err);
res.status(500).json({ error: 'Failed to create narration segments' });
} finally {
client.release();
}
});
// PUT /api/videos/:id/narration-segments/reorder
// Body: { ids: ['uuid1','uuid2',...] } — ordered array, updates sort_order by position.
router.put('/:id/narration-segments/reorder', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be an array' });
const client = await pool.connect();
try {
await client.query('BEGIN');
for (let i = 0; i < ids.length; i++) {
await client.query(
'UPDATE narration_segments SET sort_order = $1 WHERE id = $2 AND project_id = $3',
[i, ids[i], id]
);
}
await client.query('COMMIT');
res.json({ success: true });
} catch (err) {
await client.query('ROLLBACK');
console.error('Reorder segments error:', err);
res.status(500).json({ error: 'Failed to reorder' });
} finally {
client.release();
}
});
// POST /api/videos/:id/import-manuscript — bulk-create slides from a scripted manuscript
// Parses [S1], [S2]… markers; text between markers becomes presenter_notes.
// Uses ON CONFLICT to update notes if the slide already exists (safe to re-import).
router.post('/:id/import-manuscript', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
const { manuscript, component_key = 'ContentSlide', display_mode = 'fullscreen' } = req.body;
if (!manuscript?.trim()) return res.status(400).json({ error: 'manuscript is required' });
// Parse [S1], [S2]… — split produces: [before, num, text, num, text, …]
const blocks = manuscript.split(/\[S(\d+)\]/);
const parsed = [];
for (let i = 1; i < blocks.length; i += 2) {
parsed.push({
num: parseInt(blocks[i], 10),
gnommoSlideId: `S${blocks[i]}`,
notes: blocks[i + 1]?.trim() ?? '',
});
}
if (parsed.length === 0) return res.status(400).json({ error: 'No [S1]/[S2]… markers found' });
parsed.sort((a, b) => a.num - b.num);
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const { gnommoSlideId, notes, num } of parsed) {
await client.query(
`INSERT INTO slides
(video_id, gnommo_slide_id, slide_order, display_mode,
presenter_notes, component_key, props)
VALUES ($1, $2, $3, $4, $5, $6, '{}')
ON CONFLICT (video_id, gnommo_slide_id) DO UPDATE SET
presenter_notes = EXCLUDED.presenter_notes,
updated_at = NOW()`,
[id, gnommoSlideId, num, display_mode, notes, component_key]
);
}
await client.query('COMMIT');
const allSlides = await pool.query(
'SELECT * FROM slides WHERE video_id = $1 ORDER BY slide_order', [id]
);
res.json({ slides: allSlides.rows, imported: parsed.length });
} catch (err) {
await client.query('ROLLBACK');
console.error('Import manuscript error:', err);
res.status(500).json({ error: 'Failed to import manuscript' });
} finally {
client.release();
}
});
// DELETE /api/videos/:id/narration-segments/:segmentId
router.delete('/:id/narration-segments/:segmentId', async (req, res) => {
const pool = req.app.get('pool');
const { id, segmentId } = req.params;
try {
const result = await pool.query(
'DELETE FROM narration_segments WHERE id = $1 AND project_id = $2 RETURNING id',
[segmentId, id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Segment not found' });
res.json({ success: true });
} catch (err) {
console.error('Delete narration segment error:', err);
res.status(500).json({ error: 'Failed to delete narration segment' });
}
});
// GET /api/videos/:id/export — GnommoPlayer LectureDefinition
router.get('/:id/export', async (req, res) => {
const pool = req.app.get('pool');
const { id } = req.params;
try {
const videoResult = await pool.query('SELECT * FROM videos WHERE id = $1', [id]);
if (videoResult.rows.length === 0) {
return res.status(404).json({ error: 'Video not found' });
}
const video = videoResult.rows[0];
const slidesResult = await pool.query(
'SELECT * FROM slides WHERE video_id = $1 ORDER BY slide_order',
[id]
);
// Prefer 'final' segments for the player; fall back to 'raw' if none exist.
const segmentsResult = await pool.query(
`SELECT ns.*,
COALESCE(sa_id.duration_seconds, sa_key.duration_seconds) AS asset_duration
FROM narration_segments ns
LEFT JOIN shared_assets sa_id ON ns.asset_id IS NOT NULL AND sa_id.id = ns.asset_id
LEFT JOIN shared_assets sa_key ON ns.asset_id IS NULL AND sa_key.minio_object_key = ns.minio_object_key
WHERE ns.project_id = $1
AND ns.segment_type = (
SELECT CASE WHEN EXISTS(
SELECT 1 FROM narration_segments WHERE project_id = $1 AND segment_type = 'final'
) THEN 'final' ELSE 'raw' END
)
ORDER BY ns.sort_order`,
[id]
);
// Build segments with stable ids and cumulative start times
let cursor = 0;
const segments = segmentsResult.rows.map((seg) => {
const dur = parseFloat(seg.take_seconds ?? seg.asset_duration ?? 0);
const entry = {
id: seg.segment_key,
src: seg.minio_object_key ? publicUrl(seg.minio_object_key) : '',
startTimeSec: cursor,
durationSec: dur,
};
cursor += dur;
return entry;
});
const totalDur = cursor || parseFloat(video.duration_seconds ?? 0);
// Batch-fetch all candidates for this video's slides (graceful if table not yet migrated)
const candidatesBySlideId = new Map();
try {
const candidatesResult = await pool.query(
`SELECT sc.* FROM slide_candidates sc
JOIN slides s ON s.id = sc.slide_id
WHERE s.video_id = $1
ORDER BY sc.slide_id, sc.score DESC`,
[id]
);
for (const c of candidatesResult.rows) {
const key = String(c.slide_id);
if (!candidatesBySlideId.has(key)) candidatesBySlideId.set(key, []);
candidatesBySlideId.get(key).push(c);
}
} catch {
// Table doesn't exist yet — proceed with no candidates
}
// Build slides with stable ids and timing (evenly distribute if not set).
// When totalDur = 0 (no narration yet), use 1 second per slide so each slide
// gets a distinct, non-overlapping window — otherwise the player cannot
// resolve which slide to show and always lands on slide 0 or no slide.
const slideCount = slidesResult.rows.length;
const effectiveDur = totalDur > 0 ? totalDur : slideCount;
const rows = slidesResult.rows;
const slides = rows.map((s, i) => {
const slideId = s.gnommo_slide_id || `slide-${i + 1}`;
const start = s.start_time_sec !== null
? parseFloat(s.start_time_sec)
: slideCount > 0 ? (effectiveDur / slideCount) * i : 0;
let end;
if (s.end_time_sec !== null) {
end = parseFloat(s.end_time_sec);
} else {
// Prefer the next slide's explicit start time as this slide's end,
// so manually-set start times don't produce end < start.
const nextRow = rows[i + 1];
if (nextRow && nextRow.start_time_sec !== null) {
end = parseFloat(nextRow.start_time_sec);
} else {
end = slideCount > 0 ? (effectiveDur / slideCount) * (i + 1) : effectiveDur;
}
}
let glitchSlides;
if (s.component_key) {
const props = s.props || {};
const glitchSlide = {
id: `${slideId}:default`,
score: 1,
componentKey: s.component_key,
props,
};
// latexString is a top-level glitch-slide field, but the editor stores it
// inside props for convenience. Extract it so the player receives it correctly.
if (props.latexString !== undefined) {
glitchSlide.latexString = props.latexString;
}
const candidatesForSlide = candidatesBySlideId.get(String(s.id)) || [];
glitchSlides = [
glitchSlide,
...candidatesForSlide.map(c => {
const cProps = c.props || {};
const entry = {
id: `${slideId}:${c.proposed_by}`,
score: parseFloat(c.score),
componentKey: c.component_key,
props: cProps,
};
if (cProps.latexString !== undefined) entry.latexString = cProps.latexString;
return entry;
}),
];
} else {
glitchSlides = [];
}
return {
id: slideId,
startTimeSec: start,
endTimeSec: end,
displayMode: s.display_mode || 'square',
glitchSlides,
};
});
res.json({
id: video.gnommo_id || String(video.id),
title: video.title,
version: '1.0',
durationSec: effectiveDur,
segments,
slides,
});
} catch (err) {
console.error('Export error:', err);
res.status(500).json({ error: 'Failed to export video' });
}
});
export default router;
Executable
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# deploy.sh - Deploy GnommoEditor to VPS via rsync
set -e
# Configuration
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
REMOTE_DIR="${DEPLOY_DIR:-/opt/gnommoeditor}"
# Refuse to run on the production server itself
TARGET_HOST=$(echo ${SERVER} | sed 's/.*@//')
OWN_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || echo "unknown")
if [ "$OWN_IP" = "$TARGET_HOST" ]; then
echo "Error: deploy.sh must be run from your local machine, not the server."
exit 1
fi
echo "==> Deploying to ${SERVER}:${REMOTE_DIR}"
# Sync source files
echo "==> Syncing files..."
rsync -avz --delete \
--exclude 'node_modules' \
--exclude 'backend/node_modules' \
--exclude '.git' \
--exclude 'dist' \
--exclude '.env' \
--exclude '.env.local' \
--exclude '.env.prod' \
--exclude '.DS_Store' \
./ ${SERVER}:${REMOTE_DIR}/
# Ensure the gnommoeditor database exists in the shared postgres instance
echo "==> Ensuring gnommoeditor database exists..."
ssh ${SERVER} "docker exec gnommo-db psql -U \$(grep ^POSTGRES_USER ${REMOTE_DIR}/.env.prod | cut -d= -f2) -c 'CREATE DATABASE gnommoeditor;' 2>/dev/null || true"
# Build and restart containers
echo "==> Building and restarting containers..."
ssh ${SERVER} "cd ${REMOTE_DIR} && docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build"
# Wait for backend to be ready
echo "==> Waiting for backend to start..."
sleep 5
# Run database migrations
echo "==> Running database migrations..."
ssh ${SERVER} "cd ${REMOTE_DIR} && docker compose -f docker-compose.prod.yml --env-file .env.prod exec -T backend npm run migrate:up"
echo "==> Done! GnommoEditor should be live at https://editor.glitch.university"
File diff suppressed because one or more lines are too long
+77
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
import type { GlitchComponentProps } from './types';
export default function AssumptionToggle({ config, onComplete, onProgress, theme, className, host }: GlitchComponentProps): import("react/jsx-runtime").JSX.Element;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import './dev-theme.css';
@@ -0,0 +1,8 @@
import type { GlitchHostBridge } from './types';
export type DevHostBridgeOptions = {
isMuted?: () => boolean;
onSound?: (id: string, payload?: Record<string, unknown>) => void;
onEmit?: (type: string, payload?: unknown) => void;
};
export declare function createDevHostBridge(options?: DevHostBridgeOptions): GlitchHostBridge;
export declare function attachWindowSoundBridge(host: GlitchHostBridge): () => void;
@@ -0,0 +1,14 @@
{
"componentId": "assumption-toggle",
"displayName": "Assumption Toggle",
"version": "1.0.0",
"folderName": "glitch_assumption_toggle",
"packageName": "@nommo/assumption-toggle",
"entry": "dist/assumption-toggle.js",
"source": "src/index.tsx",
"tags": [
"glitch-component",
"crt",
"logic"
]
}
+17
View File
@@ -0,0 +1,17 @@
import type { GlitchHostBridge } from './types';
export declare const HOST_SOUND_EVENT = "glitch:play-sound";
export declare const HOST_STOP_SOUND_EVENT = "glitch:stop-sound";
export declare const HOST_EMIT_PREFIX = "glitch:host:";
export declare const SOUND_IDS: {
readonly click: "ui.button_click";
readonly hover: "ui.button_hover";
readonly computeStart: "machine.compute_start";
readonly computeStep: "machine.compute_step";
readonly computeDone: "machine.compute_done";
readonly verdictStable: "machine.verdict_stable";
readonly verdictAlert: "machine.verdict_alert";
};
export type SoundId = (typeof SOUND_IDS)[keyof typeof SOUND_IDS];
export declare function safePlaySound(host: GlitchHostBridge | undefined, id: string, payload?: Record<string, unknown>): void;
export declare function safeStopSound(host: GlitchHostBridge | undefined, id: string, payload?: Record<string, unknown>): void;
export declare function safeEmit(host: GlitchHostBridge | undefined, type: string, payload?: unknown): void;
+6
View File
@@ -0,0 +1,6 @@
import './styles.css';
import Component from './Component';
import type { GlitchComponentMetadata } from './types';
export default Component;
export declare const metadata: GlitchComponentMetadata;
export type { GlitchComponentProps, GlitchComponentConfig, GlitchComponentResult } from './types';
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
export interface GlitchComponentConfig {
id: string;
name: string;
version: string;
params: Record<string, unknown>;
}
export interface GlitchHostBridge {
playSound?: (id: string, payload?: Record<string, unknown>) => void;
stopSound?: (id: string, payload?: Record<string, unknown>) => void;
emit?: (type: string, payload?: unknown) => void;
}
export interface GlitchComponentProps {
config: GlitchComponentConfig;
onComplete: (result: GlitchComponentResult) => void;
onProgress?: (percent: number) => void;
theme?: GlitchTheme;
className?: string;
host?: GlitchHostBridge;
}
export interface GlitchComponentResult {
success: boolean;
score?: number;
data?: unknown;
error?: string;
}
export interface GlitchTheme {
primary: string;
accent: string;
bg: string;
bgSecondary: string;
text: string;
textMuted: string;
border: string;
}
export interface GlitchComponentMetadata {
name: string;
displayName: string;
version: string;
paramSchema: ParamSchema;
defaultParams: Record<string, unknown>;
}
export interface ParamSchema {
[key: string]: {
type: 'number' | 'string' | 'boolean' | 'color' | 'select' | 'range';
label?: string;
description?: string;
default: unknown;
options?: {
value: string | number;
label: string;
}[];
min?: number;
max?: number;
step?: number;
};
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
{
"componentId": "ccd",
"displayName": "CCD Photon Explorer",
"version": "0.1.0",
"folderName": "glitch_ccd",
"packageName": "@glitch-components/ccd",
"entry": "dist/index.html",
"source": "src/index.tsx",
"tags": [
"internal",
"visualization",
"tooling"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Glitch CCD</title>
<script type="module" crossorigin src="/assets/index-D02Mu-Ij.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Da7gUXQx.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
{
"componentId": "chess_analogy",
"displayName": "Chess Move Set Lab",
"version": "1.0.0",
"folderName": "glitch_chess_analogy",
"packageName": "@glitch-components/chess-analogy",
"entry": "dist/chess_analogy.js",
"source": "src/index.tsx",
"tags": [
"glitch-component",
"reference",
"2d",
"chess"
]
}
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{j as a,G as r}from"./index-CEeZxcxj.js";function l({className:e}){return a.jsx("div",{className:e,children:a.jsx(r,{})})}const s={name:"gallery",displayName:"Glitch Gallery",version:"0.1.0",paramSchema:{},defaultParams:{}};export{l as default,s as metadata};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
._wrapper_61dhl_1{--gc-bg: var(--color-bg, #0a0a0f);--gc-bg-secondary: var(--color-bg-secondary, #12121a);--gc-text: var(--color-text, #e8e8ec);--gc-text-muted: var(--color-text-muted, #9999a8);--gc-primary: var(--color-primary, #6366f1);--gc-accent: var(--color-accent, #22d3ee);--gc-border: var(--color-border, #2a2a3a);--gc-font-main: var(--font-main, "Iceland", -apple-system, BlinkMacSystemFont, sans-serif);--gc-font-display: var(--font-display, "Russo One", -apple-system, BlinkMacSystemFont, sans-serif);--gc-font-text: "VT323", var(--font-mono, monospace);width:100%}._frame_61dhl_15{position:relative;width:100%;height:100dvh;min-height:100vh;overflow:hidden;border:1px solid color-mix(in srgb,var(--gc-border) 60%,transparent);color:var(--gc-text);font-family:var(--gc-font-text);background:radial-gradient(circle at 18% 14%,color-mix(in srgb,var(--gc-accent) 18%,transparent),transparent 35%),radial-gradient(circle at 88% 2%,color-mix(in srgb,var(--gc-primary) 22%,transparent),transparent 30%),linear-gradient(165deg,var(--gc-bg),var(--gc-bg-secondary))}._scan_61dhl_30{position:absolute;inset:0;pointer-events:none;opacity:.18;mix-blend-mode:screen;background:repeating-linear-gradient(to bottom,rgba(255,255,255,.1) 0,rgba(255,255,255,.1) 1px,transparent 1px,transparent 3px);animation:_scanDrift_61dhl_1 8s linear infinite}._stack_61dhl_47{position:relative;z-index:1;height:100%;display:flex;flex-direction:column;justify-content:space-between;padding:22px 16px 18px}._meta_61dhl_57{display:flex;justify-content:space-between;align-items:center;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--gc-text-muted);font-family:var(--gc-font-main)}._progressTrack_61dhl_68{margin-top:8px;width:100%;height:3px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--gc-accent) 20%,transparent)}._progressFill_61dhl_77{height:100%;transition:width .24s ease;background:linear-gradient(90deg,var(--gc-accent),color-mix(in srgb,var(--gc-primary) 45%,var(--gc-accent)))}._slide_61dhl_87{flex:1;display:flex;flex-direction:column;justify-content:center;gap:20px}._title_61dhl_95{margin:0;width:fit-content;font-family:var(--gc-font-display);text-transform:uppercase;letter-spacing:.02em;line-height:1.04;font-size:clamp(28px,8.5vw,46px)}._rgbGlitch_61dhl_105{position:relative;display:inline-block}._rgbBase_61dhl_110{position:relative;z-index:1;color:color-mix(in srgb,var(--gc-text) 38%,#000)}._rgbLayer_61dhl_116{position:absolute;inset:0;z-index:2;pointer-events:none;opacity:.2}._layerR_61dhl_124{color:#ff3737bf;animation:_layerRShift_61dhl_1 2.4s infinite steps(2,end)}._layerG_61dhl_129{color:#37ff5fbf;animation:_layerGShift_61dhl_1 2.1s infinite steps(2,end)}._layerB_61dhl_134{color:#4182ffbf;animation:_layerBShift_61dhl_1 2.7s infinite steps(2,end)}._content_61dhl_139{display:flex;flex-direction:column;gap:10px;font-family:var(--gc-font-text);font-size:clamp(16px,4.2vw,22px);line-height:1.45}._line_61dhl_148{margin:0;color:var(--gc-text)}._spacer_61dhl_153{margin:0;min-height:.6em}._list_61dhl_158{margin:3px 0 0;padding-left:1.1em;display:flex;flex-direction:column;gap:7px}._warning_61dhl_166{list-style:none;padding-left:0}._warning_61dhl_166 li:before{content:"- ";color:var(--gc-accent)}._cta_61dhl_176{margin-top:10px;width:fit-content;border:1px solid var(--gc-accent);background:color-mix(in srgb,var(--gc-accent) 8%,transparent);color:var(--gc-accent);font:inherit;letter-spacing:.06em;text-transform:uppercase;padding:10px 14px;cursor:pointer;font-family:var(--gc-font-main);transition:transform .14s ease,background-color .14s ease}._cta_61dhl_176:active{transform:scale(.98)}._controls_61dhl_195{display:grid;grid-template-columns:1fr 1fr;gap:8px}._navButton_61dhl_201{border:1px solid color-mix(in srgb,var(--gc-border) 70%,transparent);background:color-mix(in srgb,var(--gc-bg-secondary) 75%,transparent);color:var(--gc-text);font:inherit;text-transform:uppercase;letter-spacing:.08em;padding:10px 12px;cursor:pointer;font-family:var(--gc-font-main)}._navButton_61dhl_201:disabled{opacity:.45;cursor:not-allowed}@keyframes _layerRShift_61dhl_1{0%,81%,to{transform:translate(0)}84%{transform:translate(-4px,2px)}88%{transform:translate(4px,-2px)}92%{transform:translate(2px)}96%{transform:translate(-2px,2px)}}@keyframes _layerGShift_61dhl_1{0%,79%,to{transform:translate(0)}83%{transform:translate(2px,-2px)}87%{transform:translate(-2px,4px)}91%{transform:translateY(-2px)}95%{transform:translate(2px)}}@keyframes _layerBShift_61dhl_1{0%,82%,to{transform:translate(0)}86%{transform:translate(4px)}90%{transform:translate(-4px,-2px)}94%{transform:translate(2px,2px)}98%{transform:translate(-2px)}}@keyframes _scanDrift_61dhl_1{0%{transform:translateY(-10px)}to{transform:translateY(10px)}}@media(min-width:600px){._frame_61dhl_15{max-width:420px;margin:0 auto}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{r as c,j as s}from"./index-CEeZxcxj.js";const w="_wrapper_61dhl_1",k="_frame_61dhl_15",v="_scan_61dhl_30",S="_stack_61dhl_47",B="_meta_61dhl_57",$="_progressTrack_61dhl_68",C="_progressFill_61dhl_77",E="_slide_61dhl_87",G="_title_61dhl_95",L="_rgbGlitch_61dhl_105",F="_rgbBase_61dhl_110",R="_rgbLayer_61dhl_116",Y="_layerR_61dhl_124",I="_layerG_61dhl_129",P="_layerB_61dhl_134",W="_content_61dhl_139",M="_line_61dhl_148",A="_spacer_61dhl_153",D="_list_61dhl_158",H="_warning_61dhl_166",O="_cta_61dhl_176",q="_controls_61dhl_195",U="_navButton_61dhl_201",e={wrapper:w,frame:k,scan:v,stack:S,meta:B,progressTrack:$,progressFill:C,slide:E,title:G,rgbGlitch:L,rgbBase:F,rgbLayer:R,layerR:Y,layerG:I,layerB:P,content:W,line:M,spacer:A,list:D,warning:H,cta:O,controls:q,navButton:U},z=40,i=[{title:"The Premise",lines:["This facility tests foundational assumptions.","","Not interpretations.","Assumptions."]},{title:"The method",lines:["This is math and philosophy.","Make assumption.","Pretend it's true.","Where does that lead?"]},{title:"The law",lines:["We make predictions.","If they fail, the idea fails."]},{title:"The way",lines:["You will:"],bullets:["Pass challenges","Collect paradoxes","Examine anomalies","Learn to think sharply"]},{title:"Warning",lines:["You may feel:"],bullets:["This is not a game!","Temporary confusion","The urge to defend existing physics!"],bulletStyle:"warning",after:"Good. This is not for everyone."},{title:"How This Works",lines:["Watch a short module","Pass the glitch gate","Unlock the next tech","That's it. For now."]},{title:"Proceed",lines:["You may proceed as an investigator.","Begin at the intersection","Get briefed"],cta:"[ ENTER FACILITY ]"}];function K({config:h,onComplete:u,onProgress:_,theme:n,className:p}){const[l,g]=c.useState(0),o=c.useRef(null),y=c.useMemo(()=>n?{"--gc-primary":n.primary,"--gc-accent":n.accent,"--gc-bg":n.bg,"--gc-bg-secondary":n.bgSecondary,"--gc-text":n.text,"--gc-text-muted":n.textMuted,"--gc-border":n.border}:{},[n]);c.useEffect(()=>{const t=(l+1)/i.length*100;_?.(Math.round(t))},[l,_]);const d=c.useCallback(t=>{if(t>0&&l===i.length-1){g(0);return}const r=l+t;r<0||r>=i.length||g(r)},[l]),b=c.useCallback(t=>{o.current=t.changedTouches[0]?.clientY??null},[]),x=c.useCallback(t=>{if(o.current==null)return;const r=t.changedTouches[0]?.clientY??o.current,m=o.current-r;Math.abs(m)>z&&d(m>0?1:-1),o.current=null},[d]),f=c.useCallback(()=>{u({success:!0,score:100,data:{sequence:"facility-slideshow",configId:h.id,completedAt:new Date().toISOString()}})},[h.id,u]),a=i[l],j=l===i.length-1,N=(l+1)/i.length*100,T=p?`${e.wrapper} ${p}`:e.wrapper;return s.jsx("div",{className:T,style:y,children:s.jsxs("section",{className:e.frame,"aria-label":"Facility onboarding slideshow",onTouchStart:b,onTouchEnd:x,children:[s.jsx("div",{className:e.scan,"aria-hidden":"true"}),s.jsxs("div",{className:e.stack,children:[s.jsxs("header",{children:[s.jsxs("div",{className:e.meta,children:[s.jsx("span",{children:"Glitch Facility"}),s.jsxs("span",{children:[l+1," / ",i.length]})]}),s.jsx("div",{className:e.progressTrack,children:s.jsx("div",{className:e.progressFill,style:{width:`${N}%`}})})]}),s.jsxs("article",{className:e.slide,"aria-live":"polite",children:[s.jsx("h2",{className:e.title,children:s.jsxs("span",{className:e.rgbGlitch,children:[s.jsx("span",{className:e.rgbBase,children:a.title}),s.jsx("span",{className:`${e.rgbLayer} ${e.layerR}`,"aria-hidden":"true",children:a.title}),s.jsx("span",{className:`${e.rgbLayer} ${e.layerG}`,"aria-hidden":"true",children:a.title}),s.jsx("span",{className:`${e.rgbLayer} ${e.layerB}`,"aria-hidden":"true",children:a.title})]})}),s.jsxs("div",{className:e.content,children:[a.lines.map((t,r)=>t===""?s.jsx("p",{className:e.spacer,"aria-hidden":"true"},`line-${r}`):s.jsx("p",{className:e.line,children:t},`line-${r}`)),a.bullets&&s.jsx("ul",{className:`${e.list} ${a.bulletStyle==="warning"?e.warning:""}`,children:a.bullets.map(t=>s.jsx("li",{children:t},t))}),a.after&&s.jsx("p",{className:e.line,children:a.after}),a.cta&&s.jsx("button",{type:"button",className:e.cta,onClick:f,children:a.cta})]})]}),s.jsxs("nav",{className:e.controls,"aria-label":"Slideshow controls",children:[s.jsx("button",{type:"button",className:e.navButton,onClick:()=>d(-1),disabled:l===0,children:"Previous"}),s.jsx("button",{type:"button",className:e.navButton,onClick:()=>d(1),children:j?"Restart":"Next"})]})]})]})})}const Q={name:"onboarding2",displayName:"Facility Slideshow",version:"1.0.0",paramSchema:{},defaultParams:{}};export{K as default,Q as metadata};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
._wrapper_1xs2z_1{--gc-paper: #f2f0ea;box-sizing:border-box;width:100%;height:100%;min-height:100%;margin:0;padding:0;border:1px solid color-mix(in srgb,var(--gc-border, #34516f) 72%,transparent);border-radius:24px;background:radial-gradient(circle at 12% 8%,color-mix(in srgb,var(--gc-primary, #45c4b0) 16%,transparent),transparent 34%),radial-gradient(circle at 88% 10%,color-mix(in srgb,var(--gc-accent, #ff7a59) 14%,transparent),transparent 28%),linear-gradient(160deg,var(--gc-bg, #0d1117),var(--gc-bg-secondary, #182237));color:var(--gc-text, #f1f4ef);box-shadow:0 24px 64px #00000047;display:grid;place-items:center}._wrapper_1xs2z_1,._wrapper_1xs2z_1 *,._wrapper_1xs2z_1 *:before,._wrapper_1xs2z_1 *:after{box-sizing:inherit}._canvasShell_1xs2z_28{position:relative;overflow:hidden;flex:none;border:1px solid rgb(0 0 0 / 22%);background:var(--gc-paper, #f2f0ea);box-shadow:inset 0 0 0 1px #ffffff1f}._canvas_1xs2z_28{width:100%;height:100%}._canvas_1xs2z_28 canvas{display:block;width:100%;height:100%}._viewportLabels_1xs2z_48{position:absolute;inset:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));pointer-events:none}._viewportLabels_1xs2z_48[data-layout=portrait]{grid-template-columns:1fr;grid-template-rows:repeat(2,minmax(0,1fr))}._splitLabel_1xs2z_61{display:grid;place-items:center;color:#111827d1;font-size:clamp(1.15rem,2.7vw,2.1rem);font-weight:700;font-family:IBM Plex Sans Condensed,Avenir Next Condensed,sans-serif;text-transform:uppercase;letter-spacing:.34em;text-shadow:0 2px 12px rgb(255 255 255 / 42%)}._controlOverlay_1xs2z_73{position:absolute;right:1rem;bottom:1rem;left:1rem;display:flex;justify-content:center;pointer-events:none}._resolutionCard_1xs2z_83{pointer-events:auto;display:flex;align-items:center;justify-content:center;width:min(100%,26rem);padding:.75rem .9rem;border:1px solid color-mix(in srgb,var(--gc-border, #34516f) 72%,transparent);border-radius:16px;background:#070f199e;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 10px 30px #0000002e}._resolutionControl_1xs2z_97{display:flex;align-items:center;gap:.85rem;width:100%;min-width:0}._slider_1xs2z_105{width:100%;accent-color:var(--gc-primary, #45c4b0);cursor:pointer}._sliderValue_1xs2z_111{min-width:3.75rem;text-align:right;font-family:IBM Plex Mono,JetBrains Mono,monospace;font-size:.95rem}@media(max-width:900px){._resolutionControl_1xs2z_97{min-width:0}}@media(max-width:720px){._wrapper_1xs2z_1{padding:0}._splitLabel_1xs2z_61{font-size:clamp(.95rem,5vw,1.4rem);letter-spacing:.24em}._controlOverlay_1xs2z_73{right:0rem;bottom:0rem;left:0rem}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 826 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"componentId": "gallery",
"displayName": "Glitch Gallery",
"version": "0.1.0",
"folderName": "glitch_gallery",
"packageName": "@glitch-components/gallery",
"entry": "dist/index.html",
"source": "src/index.tsx",
"tags": [
"internal",
"tooling",
"preview"
]
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Glitch Gallery</title>
<script type="module" crossorigin src="/assets/index-CEeZxcxj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DczjPplt.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
{
"componentId": "level-questions",
"displayName": "Level Questions",
"version": "1.0.0",
"folderName": "glitch_level_questions",
"packageName": "level-questions",
"entry": "dist/level-questions.js",
"source": "src/index.tsx",
"tags": [
"glitch-component",
"quiz",
"role-mapping"
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
import { type GlitchComponentProps } from './types';
type ComponentDevUiOverrides = {
showNumericDiagnostics?: boolean;
compactNumericDiagnostics?: boolean;
onOpenHarness?: () => void;
onSetParam?: (name: 'complexity' | 'speedup', value: number) => void;
maxComplexity?: number;
maxSpeedup?: number;
};
type ComponentProps = GlitchComponentProps & {
devUi?: ComponentDevUiOverrides;
};
export default function Component(props: ComponentProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,10 @@
interface DiophantineVectorsProps {
complexity: number;
lightlanes?: boolean;
radius?: number;
distance?: number;
allSectors?: boolean;
cumulative?: boolean;
}
export declare function DiophantineVectors({ complexity, lightlanes, radius, distance, allSectors, cumulative }: DiophantineVectorsProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,22 @@
import type { GlitchHostBridge } from '../types';
interface FanoSweepPanelProps {
radius: number;
distance: number;
chandraDistance: number;
allSectors: boolean;
cumulative: boolean;
theta: number;
phi: number;
orbitSpeed: number;
thetaDrift: number;
thetaJitter: number;
phiJitter: number;
K: number;
speedup: number;
usePoisson: boolean;
bottomInset?: number;
onBack?: () => void;
host?: GlitchHostBridge;
}
export declare function FanoSweepPanel(props: FanoSweepPanelProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,12 @@
import { type MutableRefObject } from 'react';
interface OrbitingObjectProps {
radius: number;
theta: number;
phi: number;
orbitTime: MutableRefObject<number>;
simTime?: MutableRefObject<number>;
thetaDrift?: number;
paused?: boolean;
}
export declare function OrbitingObject({ radius, theta, phi, orbitTime, simTime, thetaDrift, paused }: OrbitingObjectProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,3 @@
export declare function PoissonShell({ radius }: {
radius: number;
}): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
interface PulsingSphereProps {
radius: number;
paused?: boolean;
}
export declare function PulsingSphere({ radius, paused }: PulsingSphereProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,29 @@
import type { GlitchHostBridge, SceneViewMode } from '../types';
interface SamplingSystemProps {
complexity: number;
radius: number;
distance: number;
theta: number;
phi: number;
observationMode: 'orbit' | 'randomSphere';
allSectors: boolean;
cumulative: boolean;
K: number;
orbitRadius: number;
orbitVelocity: number;
thetaDrift: number;
thetaJitter: number;
phiJitter: number;
usePoisson: boolean;
speedup: number;
paused: boolean;
sceneView: SceneViewMode;
bottomInset?: number;
showNumericDiagnostics?: boolean;
compactNumericDiagnostics?: boolean;
host?: GlitchHostBridge;
centerDistribution?: boolean;
hideFlyingBars?: boolean;
}
export declare function SamplingSystem({ complexity, radius, distance, theta, phi, observationMode, allSectors, cumulative, K, orbitRadius, orbitVelocity, thetaDrift, thetaJitter, phiJitter, usePoisson, speedup, paused, sceneView, bottomInset, showNumericDiagnostics, compactNumericDiagnostics, host, centerDistribution, hideFlyingBars }: SamplingSystemProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,6 @@
interface TargetDiscProps {
radius: number;
distance: number;
}
export declare function TargetDisc({ radius, distance }: TargetDiscProps): import("react/jsx-runtime").JSX.Element;
export {};
@@ -0,0 +1,7 @@
interface VectorArrowProps {
vector: [number, number, number];
origin?: [number, number, number];
color?: string;
}
export declare function VectorArrow({ vector, origin, color }: VectorArrowProps): import("react/jsx-runtime").JSX.Element;
export {};
+1
View File
@@ -0,0 +1 @@
import './dev-theme.css';

Some files were not shown because too many files have changed in this diff Show More