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
+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;