Adding gnommoeditor in the current version
This commit is contained in:
@@ -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"]
|
||||
@@ -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': '^\\..*',
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
};
|
||||
@@ -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'); };
|
||||
@@ -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']);
|
||||
};
|
||||
@@ -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']);
|
||||
};
|
||||
Generated
+3258
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user