Adding gnommoeditor in the current version
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# Stage 1: build the Vite app
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# VITE_ env vars are baked into the bundle at build time
|
||||||
|
ARG VITE_GNOMMOWEB_URL
|
||||||
|
ENV VITE_GNOMMOWEB_URL=${VITE_GNOMMOWEB_URL}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx/nginx.prod.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy.sh - Deploy GnommoEditor to VPS via rsync
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
|
||||||
|
REMOTE_DIR="${DEPLOY_DIR:-/opt/gnommoeditor}"
|
||||||
|
|
||||||
|
# Refuse to run on the production server itself
|
||||||
|
TARGET_HOST=$(echo ${SERVER} | sed 's/.*@//')
|
||||||
|
OWN_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$OWN_IP" = "$TARGET_HOST" ]; then
|
||||||
|
echo "Error: deploy.sh must be run from your local machine, not the server."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Deploying to ${SERVER}:${REMOTE_DIR}"
|
||||||
|
|
||||||
|
# Sync source files
|
||||||
|
echo "==> Syncing files..."
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'backend/node_modules' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude '.env.local' \
|
||||||
|
--exclude '.env.prod' \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
./ ${SERVER}:${REMOTE_DIR}/
|
||||||
|
|
||||||
|
# Ensure the gnommoeditor database exists in the shared postgres instance
|
||||||
|
echo "==> Ensuring gnommoeditor database exists..."
|
||||||
|
ssh ${SERVER} "docker exec gnommo-db psql -U \$(grep ^POSTGRES_USER ${REMOTE_DIR}/.env.prod | cut -d= -f2) -c 'CREATE DATABASE gnommoeditor;' 2>/dev/null || true"
|
||||||
|
|
||||||
|
# Build and restart containers
|
||||||
|
echo "==> Building and restarting containers..."
|
||||||
|
ssh ${SERVER} "cd ${REMOTE_DIR} && docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build"
|
||||||
|
|
||||||
|
# Wait for backend to be ready
|
||||||
|
echo "==> Waiting for backend to start..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
echo "==> Running database migrations..."
|
||||||
|
ssh ${SERVER} "cd ${REMOTE_DIR} && docker compose -f docker-compose.prod.yml --env-file .env.prod exec -T backend npm run migrate:up"
|
||||||
|
|
||||||
|
echo "==> Done! GnommoEditor should be live at https://editor.glitch.university"
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+77
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
import type { GlitchComponentProps } from './types';
|
||||||
|
export default function AssumptionToggle({ config, onComplete, onProgress, theme, className, host }: GlitchComponentProps): import("react/jsx-runtime").JSX.Element;
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import './dev-theme.css';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { GlitchHostBridge } from './types';
|
||||||
|
export type DevHostBridgeOptions = {
|
||||||
|
isMuted?: () => boolean;
|
||||||
|
onSound?: (id: string, payload?: Record<string, unknown>) => void;
|
||||||
|
onEmit?: (type: string, payload?: unknown) => void;
|
||||||
|
};
|
||||||
|
export declare function createDevHostBridge(options?: DevHostBridgeOptions): GlitchHostBridge;
|
||||||
|
export declare function attachWindowSoundBridge(host: GlitchHostBridge): () => void;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"componentId": "assumption-toggle",
|
||||||
|
"displayName": "Assumption Toggle",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"folderName": "glitch_assumption_toggle",
|
||||||
|
"packageName": "@nommo/assumption-toggle",
|
||||||
|
"entry": "dist/assumption-toggle.js",
|
||||||
|
"source": "src/index.tsx",
|
||||||
|
"tags": [
|
||||||
|
"glitch-component",
|
||||||
|
"crt",
|
||||||
|
"logic"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { GlitchHostBridge } from './types';
|
||||||
|
export declare const HOST_SOUND_EVENT = "glitch:play-sound";
|
||||||
|
export declare const HOST_STOP_SOUND_EVENT = "glitch:stop-sound";
|
||||||
|
export declare const HOST_EMIT_PREFIX = "glitch:host:";
|
||||||
|
export declare const SOUND_IDS: {
|
||||||
|
readonly click: "ui.button_click";
|
||||||
|
readonly hover: "ui.button_hover";
|
||||||
|
readonly computeStart: "machine.compute_start";
|
||||||
|
readonly computeStep: "machine.compute_step";
|
||||||
|
readonly computeDone: "machine.compute_done";
|
||||||
|
readonly verdictStable: "machine.verdict_stable";
|
||||||
|
readonly verdictAlert: "machine.verdict_alert";
|
||||||
|
};
|
||||||
|
export type SoundId = (typeof SOUND_IDS)[keyof typeof SOUND_IDS];
|
||||||
|
export declare function safePlaySound(host: GlitchHostBridge | undefined, id: string, payload?: Record<string, unknown>): void;
|
||||||
|
export declare function safeStopSound(host: GlitchHostBridge | undefined, id: string, payload?: Record<string, unknown>): void;
|
||||||
|
export declare function safeEmit(host: GlitchHostBridge | undefined, type: string, payload?: unknown): void;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import './styles.css';
|
||||||
|
import Component from './Component';
|
||||||
|
import type { GlitchComponentMetadata } from './types';
|
||||||
|
export default Component;
|
||||||
|
export declare const metadata: GlitchComponentMetadata;
|
||||||
|
export type { GlitchComponentProps, GlitchComponentConfig, GlitchComponentResult } from './types';
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56
@@ -0,0 +1,56 @@
|
|||||||
|
export interface GlitchComponentConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
export interface GlitchHostBridge {
|
||||||
|
playSound?: (id: string, payload?: Record<string, unknown>) => void;
|
||||||
|
stopSound?: (id: string, payload?: Record<string, unknown>) => void;
|
||||||
|
emit?: (type: string, payload?: unknown) => void;
|
||||||
|
}
|
||||||
|
export interface GlitchComponentProps {
|
||||||
|
config: GlitchComponentConfig;
|
||||||
|
onComplete: (result: GlitchComponentResult) => void;
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
|
theme?: GlitchTheme;
|
||||||
|
className?: string;
|
||||||
|
host?: GlitchHostBridge;
|
||||||
|
}
|
||||||
|
export interface GlitchComponentResult {
|
||||||
|
success: boolean;
|
||||||
|
score?: number;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
export interface GlitchTheme {
|
||||||
|
primary: string;
|
||||||
|
accent: string;
|
||||||
|
bg: string;
|
||||||
|
bgSecondary: string;
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
border: string;
|
||||||
|
}
|
||||||
|
export interface GlitchComponentMetadata {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
version: string;
|
||||||
|
paramSchema: ParamSchema;
|
||||||
|
defaultParams: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
export interface ParamSchema {
|
||||||
|
[key: string]: {
|
||||||
|
type: 'number' | 'string' | 'boolean' | 'color' | 'select' | 'range';
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
default: unknown;
|
||||||
|
options?: {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"componentId": "ccd",
|
||||||
|
"displayName": "CCD Photon Explorer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"folderName": "glitch_ccd",
|
||||||
|
"packageName": "@glitch-components/ccd",
|
||||||
|
"entry": "dist/index.html",
|
||||||
|
"source": "src/index.tsx",
|
||||||
|
"tags": [
|
||||||
|
"internal",
|
||||||
|
"visualization",
|
||||||
|
"tooling"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Glitch CCD</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-D02Mu-Ij.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Da7gUXQx.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+439
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"componentId": "chess_analogy",
|
||||||
|
"displayName": "Chess Move Set Lab",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"folderName": "glitch_chess_analogy",
|
||||||
|
"packageName": "@glitch-components/chess-analogy",
|
||||||
|
"entry": "dist/chess_analogy.js",
|
||||||
|
"source": "src/index.tsx",
|
||||||
|
"tags": [
|
||||||
|
"glitch-component",
|
||||||
|
"reference",
|
||||||
|
"2d",
|
||||||
|
"chess"
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+125
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{j as a,G as r}from"./index-CEeZxcxj.js";function l({className:e}){return a.jsx("div",{className:e,children:a.jsx(r,{})})}const s={name:"gallery",displayName:"Glitch Gallery",version:"0.1.0",paramSchema:{},defaultParams:{}};export{l as default,s as metadata};
|
||||||
+4031
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
._wrapper_61dhl_1{--gc-bg: var(--color-bg, #0a0a0f);--gc-bg-secondary: var(--color-bg-secondary, #12121a);--gc-text: var(--color-text, #e8e8ec);--gc-text-muted: var(--color-text-muted, #9999a8);--gc-primary: var(--color-primary, #6366f1);--gc-accent: var(--color-accent, #22d3ee);--gc-border: var(--color-border, #2a2a3a);--gc-font-main: var(--font-main, "Iceland", -apple-system, BlinkMacSystemFont, sans-serif);--gc-font-display: var(--font-display, "Russo One", -apple-system, BlinkMacSystemFont, sans-serif);--gc-font-text: "VT323", var(--font-mono, monospace);width:100%}._frame_61dhl_15{position:relative;width:100%;height:100dvh;min-height:100vh;overflow:hidden;border:1px solid color-mix(in srgb,var(--gc-border) 60%,transparent);color:var(--gc-text);font-family:var(--gc-font-text);background:radial-gradient(circle at 18% 14%,color-mix(in srgb,var(--gc-accent) 18%,transparent),transparent 35%),radial-gradient(circle at 88% 2%,color-mix(in srgb,var(--gc-primary) 22%,transparent),transparent 30%),linear-gradient(165deg,var(--gc-bg),var(--gc-bg-secondary))}._scan_61dhl_30{position:absolute;inset:0;pointer-events:none;opacity:.18;mix-blend-mode:screen;background:repeating-linear-gradient(to bottom,rgba(255,255,255,.1) 0,rgba(255,255,255,.1) 1px,transparent 1px,transparent 3px);animation:_scanDrift_61dhl_1 8s linear infinite}._stack_61dhl_47{position:relative;z-index:1;height:100%;display:flex;flex-direction:column;justify-content:space-between;padding:22px 16px 18px}._meta_61dhl_57{display:flex;justify-content:space-between;align-items:center;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--gc-text-muted);font-family:var(--gc-font-main)}._progressTrack_61dhl_68{margin-top:8px;width:100%;height:3px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--gc-accent) 20%,transparent)}._progressFill_61dhl_77{height:100%;transition:width .24s ease;background:linear-gradient(90deg,var(--gc-accent),color-mix(in srgb,var(--gc-primary) 45%,var(--gc-accent)))}._slide_61dhl_87{flex:1;display:flex;flex-direction:column;justify-content:center;gap:20px}._title_61dhl_95{margin:0;width:fit-content;font-family:var(--gc-font-display);text-transform:uppercase;letter-spacing:.02em;line-height:1.04;font-size:clamp(28px,8.5vw,46px)}._rgbGlitch_61dhl_105{position:relative;display:inline-block}._rgbBase_61dhl_110{position:relative;z-index:1;color:color-mix(in srgb,var(--gc-text) 38%,#000)}._rgbLayer_61dhl_116{position:absolute;inset:0;z-index:2;pointer-events:none;opacity:.2}._layerR_61dhl_124{color:#ff3737bf;animation:_layerRShift_61dhl_1 2.4s infinite steps(2,end)}._layerG_61dhl_129{color:#37ff5fbf;animation:_layerGShift_61dhl_1 2.1s infinite steps(2,end)}._layerB_61dhl_134{color:#4182ffbf;animation:_layerBShift_61dhl_1 2.7s infinite steps(2,end)}._content_61dhl_139{display:flex;flex-direction:column;gap:10px;font-family:var(--gc-font-text);font-size:clamp(16px,4.2vw,22px);line-height:1.45}._line_61dhl_148{margin:0;color:var(--gc-text)}._spacer_61dhl_153{margin:0;min-height:.6em}._list_61dhl_158{margin:3px 0 0;padding-left:1.1em;display:flex;flex-direction:column;gap:7px}._warning_61dhl_166{list-style:none;padding-left:0}._warning_61dhl_166 li:before{content:"- ";color:var(--gc-accent)}._cta_61dhl_176{margin-top:10px;width:fit-content;border:1px solid var(--gc-accent);background:color-mix(in srgb,var(--gc-accent) 8%,transparent);color:var(--gc-accent);font:inherit;letter-spacing:.06em;text-transform:uppercase;padding:10px 14px;cursor:pointer;font-family:var(--gc-font-main);transition:transform .14s ease,background-color .14s ease}._cta_61dhl_176:active{transform:scale(.98)}._controls_61dhl_195{display:grid;grid-template-columns:1fr 1fr;gap:8px}._navButton_61dhl_201{border:1px solid color-mix(in srgb,var(--gc-border) 70%,transparent);background:color-mix(in srgb,var(--gc-bg-secondary) 75%,transparent);color:var(--gc-text);font:inherit;text-transform:uppercase;letter-spacing:.08em;padding:10px 12px;cursor:pointer;font-family:var(--gc-font-main)}._navButton_61dhl_201:disabled{opacity:.45;cursor:not-allowed}@keyframes _layerRShift_61dhl_1{0%,81%,to{transform:translate(0)}84%{transform:translate(-4px,2px)}88%{transform:translate(4px,-2px)}92%{transform:translate(2px)}96%{transform:translate(-2px,2px)}}@keyframes _layerGShift_61dhl_1{0%,79%,to{transform:translate(0)}83%{transform:translate(2px,-2px)}87%{transform:translate(-2px,4px)}91%{transform:translateY(-2px)}95%{transform:translate(2px)}}@keyframes _layerBShift_61dhl_1{0%,82%,to{transform:translate(0)}86%{transform:translate(4px)}90%{transform:translate(-4px,-2px)}94%{transform:translate(2px,2px)}98%{transform:translate(-2px)}}@keyframes _scanDrift_61dhl_1{0%{transform:translateY(-10px)}to{transform:translateY(10px)}}@media(min-width:600px){._frame_61dhl_15{max-width:420px;margin:0 auto}}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{r as c,j as s}from"./index-CEeZxcxj.js";const w="_wrapper_61dhl_1",k="_frame_61dhl_15",v="_scan_61dhl_30",S="_stack_61dhl_47",B="_meta_61dhl_57",$="_progressTrack_61dhl_68",C="_progressFill_61dhl_77",E="_slide_61dhl_87",G="_title_61dhl_95",L="_rgbGlitch_61dhl_105",F="_rgbBase_61dhl_110",R="_rgbLayer_61dhl_116",Y="_layerR_61dhl_124",I="_layerG_61dhl_129",P="_layerB_61dhl_134",W="_content_61dhl_139",M="_line_61dhl_148",A="_spacer_61dhl_153",D="_list_61dhl_158",H="_warning_61dhl_166",O="_cta_61dhl_176",q="_controls_61dhl_195",U="_navButton_61dhl_201",e={wrapper:w,frame:k,scan:v,stack:S,meta:B,progressTrack:$,progressFill:C,slide:E,title:G,rgbGlitch:L,rgbBase:F,rgbLayer:R,layerR:Y,layerG:I,layerB:P,content:W,line:M,spacer:A,list:D,warning:H,cta:O,controls:q,navButton:U},z=40,i=[{title:"The Premise",lines:["This facility tests foundational assumptions.","","Not interpretations.","Assumptions."]},{title:"The method",lines:["This is math and philosophy.","Make assumption.","Pretend it's true.","Where does that lead?"]},{title:"The law",lines:["We make predictions.","If they fail, the idea fails."]},{title:"The way",lines:["You will:"],bullets:["Pass challenges","Collect paradoxes","Examine anomalies","Learn to think sharply"]},{title:"Warning",lines:["You may feel:"],bullets:["This is not a game!","Temporary confusion","The urge to defend existing physics!"],bulletStyle:"warning",after:"Good. This is not for everyone."},{title:"How This Works",lines:["Watch a short module","Pass the glitch gate","Unlock the next tech","That's it. For now."]},{title:"Proceed",lines:["You may proceed as an investigator.","Begin at the intersection","Get briefed"],cta:"[ ENTER FACILITY ]"}];function K({config:h,onComplete:u,onProgress:_,theme:n,className:p}){const[l,g]=c.useState(0),o=c.useRef(null),y=c.useMemo(()=>n?{"--gc-primary":n.primary,"--gc-accent":n.accent,"--gc-bg":n.bg,"--gc-bg-secondary":n.bgSecondary,"--gc-text":n.text,"--gc-text-muted":n.textMuted,"--gc-border":n.border}:{},[n]);c.useEffect(()=>{const t=(l+1)/i.length*100;_?.(Math.round(t))},[l,_]);const d=c.useCallback(t=>{if(t>0&&l===i.length-1){g(0);return}const r=l+t;r<0||r>=i.length||g(r)},[l]),b=c.useCallback(t=>{o.current=t.changedTouches[0]?.clientY??null},[]),x=c.useCallback(t=>{if(o.current==null)return;const r=t.changedTouches[0]?.clientY??o.current,m=o.current-r;Math.abs(m)>z&&d(m>0?1:-1),o.current=null},[d]),f=c.useCallback(()=>{u({success:!0,score:100,data:{sequence:"facility-slideshow",configId:h.id,completedAt:new Date().toISOString()}})},[h.id,u]),a=i[l],j=l===i.length-1,N=(l+1)/i.length*100,T=p?`${e.wrapper} ${p}`:e.wrapper;return s.jsx("div",{className:T,style:y,children:s.jsxs("section",{className:e.frame,"aria-label":"Facility onboarding slideshow",onTouchStart:b,onTouchEnd:x,children:[s.jsx("div",{className:e.scan,"aria-hidden":"true"}),s.jsxs("div",{className:e.stack,children:[s.jsxs("header",{children:[s.jsxs("div",{className:e.meta,children:[s.jsx("span",{children:"Glitch Facility"}),s.jsxs("span",{children:[l+1," / ",i.length]})]}),s.jsx("div",{className:e.progressTrack,children:s.jsx("div",{className:e.progressFill,style:{width:`${N}%`}})})]}),s.jsxs("article",{className:e.slide,"aria-live":"polite",children:[s.jsx("h2",{className:e.title,children:s.jsxs("span",{className:e.rgbGlitch,children:[s.jsx("span",{className:e.rgbBase,children:a.title}),s.jsx("span",{className:`${e.rgbLayer} ${e.layerR}`,"aria-hidden":"true",children:a.title}),s.jsx("span",{className:`${e.rgbLayer} ${e.layerG}`,"aria-hidden":"true",children:a.title}),s.jsx("span",{className:`${e.rgbLayer} ${e.layerB}`,"aria-hidden":"true",children:a.title})]})}),s.jsxs("div",{className:e.content,children:[a.lines.map((t,r)=>t===""?s.jsx("p",{className:e.spacer,"aria-hidden":"true"},`line-${r}`):s.jsx("p",{className:e.line,children:t},`line-${r}`)),a.bullets&&s.jsx("ul",{className:`${e.list} ${a.bulletStyle==="warning"?e.warning:""}`,children:a.bullets.map(t=>s.jsx("li",{children:t},t))}),a.after&&s.jsx("p",{className:e.line,children:a.after}),a.cta&&s.jsx("button",{type:"button",className:e.cta,onClick:f,children:a.cta})]})]}),s.jsxs("nav",{className:e.controls,"aria-label":"Slideshow controls",children:[s.jsx("button",{type:"button",className:e.navButton,onClick:()=>d(-1),disabled:l===0,children:"Previous"}),s.jsx("button",{type:"button",className:e.navButton,onClick:()=>d(1),children:j?"Restart":"Next"})]})]})]})})}const Q={name:"onboarding2",displayName:"Facility Slideshow",version:"1.0.0",paramSchema:{},defaultParams:{}};export{K as default,Q as metadata};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
._wrapper_1xs2z_1{--gc-paper: #f2f0ea;box-sizing:border-box;width:100%;height:100%;min-height:100%;margin:0;padding:0;border:1px solid color-mix(in srgb,var(--gc-border, #34516f) 72%,transparent);border-radius:24px;background:radial-gradient(circle at 12% 8%,color-mix(in srgb,var(--gc-primary, #45c4b0) 16%,transparent),transparent 34%),radial-gradient(circle at 88% 10%,color-mix(in srgb,var(--gc-accent, #ff7a59) 14%,transparent),transparent 28%),linear-gradient(160deg,var(--gc-bg, #0d1117),var(--gc-bg-secondary, #182237));color:var(--gc-text, #f1f4ef);box-shadow:0 24px 64px #00000047;display:grid;place-items:center}._wrapper_1xs2z_1,._wrapper_1xs2z_1 *,._wrapper_1xs2z_1 *:before,._wrapper_1xs2z_1 *:after{box-sizing:inherit}._canvasShell_1xs2z_28{position:relative;overflow:hidden;flex:none;border:1px solid rgb(0 0 0 / 22%);background:var(--gc-paper, #f2f0ea);box-shadow:inset 0 0 0 1px #ffffff1f}._canvas_1xs2z_28{width:100%;height:100%}._canvas_1xs2z_28 canvas{display:block;width:100%;height:100%}._viewportLabels_1xs2z_48{position:absolute;inset:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));pointer-events:none}._viewportLabels_1xs2z_48[data-layout=portrait]{grid-template-columns:1fr;grid-template-rows:repeat(2,minmax(0,1fr))}._splitLabel_1xs2z_61{display:grid;place-items:center;color:#111827d1;font-size:clamp(1.15rem,2.7vw,2.1rem);font-weight:700;font-family:IBM Plex Sans Condensed,Avenir Next Condensed,sans-serif;text-transform:uppercase;letter-spacing:.34em;text-shadow:0 2px 12px rgb(255 255 255 / 42%)}._controlOverlay_1xs2z_73{position:absolute;right:1rem;bottom:1rem;left:1rem;display:flex;justify-content:center;pointer-events:none}._resolutionCard_1xs2z_83{pointer-events:auto;display:flex;align-items:center;justify-content:center;width:min(100%,26rem);padding:.75rem .9rem;border:1px solid color-mix(in srgb,var(--gc-border, #34516f) 72%,transparent);border-radius:16px;background:#070f199e;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 10px 30px #0000002e}._resolutionControl_1xs2z_97{display:flex;align-items:center;gap:.85rem;width:100%;min-width:0}._slider_1xs2z_105{width:100%;accent-color:var(--gc-primary, #45c4b0);cursor:pointer}._sliderValue_1xs2z_111{min-width:3.75rem;text-align:right;font-family:IBM Plex Mono,JetBrains Mono,monospace;font-size:.95rem}@media(max-width:900px){._resolutionControl_1xs2z_97{min-width:0}}@media(max-width:720px){._wrapper_1xs2z_1{padding:0}._splitLabel_1xs2z_61{font-size:clamp(.95rem,5vw,1.4rem);letter-spacing:.24em}._controlOverlay_1xs2z_73{right:0rem;bottom:0rem;left:0rem}}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 826 KiB |
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"componentId": "gallery",
|
||||||
|
"displayName": "Glitch Gallery",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"folderName": "glitch_gallery",
|
||||||
|
"packageName": "@glitch-components/gallery",
|
||||||
|
"entry": "dist/index.html",
|
||||||
|
"source": "src/index.tsx",
|
||||||
|
"tags": [
|
||||||
|
"internal",
|
||||||
|
"tooling",
|
||||||
|
"preview"
|
||||||
|
]
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Glitch Gallery</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-CEeZxcxj.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DczjPplt.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"componentId": "level-questions",
|
||||||
|
"displayName": "Level Questions",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"folderName": "glitch_level_questions",
|
||||||
|
"packageName": "level-questions",
|
||||||
|
"entry": "dist/level-questions.js",
|
||||||
|
"source": "src/index.tsx",
|
||||||
|
"tags": [
|
||||||
|
"glitch-component",
|
||||||
|
"quiz",
|
||||||
|
"role-mapping"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import { type GlitchComponentProps } from './types';
|
||||||
|
type ComponentDevUiOverrides = {
|
||||||
|
showNumericDiagnostics?: boolean;
|
||||||
|
compactNumericDiagnostics?: boolean;
|
||||||
|
onOpenHarness?: () => void;
|
||||||
|
onSetParam?: (name: 'complexity' | 'speedup', value: number) => void;
|
||||||
|
maxComplexity?: number;
|
||||||
|
maxSpeedup?: number;
|
||||||
|
};
|
||||||
|
type ComponentProps = GlitchComponentProps & {
|
||||||
|
devUi?: ComponentDevUiOverrides;
|
||||||
|
};
|
||||||
|
export default function Component(props: ComponentProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
interface DiophantineVectorsProps {
|
||||||
|
complexity: number;
|
||||||
|
lightlanes?: boolean;
|
||||||
|
radius?: number;
|
||||||
|
distance?: number;
|
||||||
|
allSectors?: boolean;
|
||||||
|
cumulative?: boolean;
|
||||||
|
}
|
||||||
|
export declare function DiophantineVectors({ complexity, lightlanes, radius, distance, allSectors, cumulative }: DiophantineVectorsProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { GlitchHostBridge } from '../types';
|
||||||
|
interface FanoSweepPanelProps {
|
||||||
|
radius: number;
|
||||||
|
distance: number;
|
||||||
|
chandraDistance: number;
|
||||||
|
allSectors: boolean;
|
||||||
|
cumulative: boolean;
|
||||||
|
theta: number;
|
||||||
|
phi: number;
|
||||||
|
orbitSpeed: number;
|
||||||
|
thetaDrift: number;
|
||||||
|
thetaJitter: number;
|
||||||
|
phiJitter: number;
|
||||||
|
K: number;
|
||||||
|
speedup: number;
|
||||||
|
usePoisson: boolean;
|
||||||
|
bottomInset?: number;
|
||||||
|
onBack?: () => void;
|
||||||
|
host?: GlitchHostBridge;
|
||||||
|
}
|
||||||
|
export declare function FanoSweepPanel(props: FanoSweepPanelProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { type MutableRefObject } from 'react';
|
||||||
|
interface OrbitingObjectProps {
|
||||||
|
radius: number;
|
||||||
|
theta: number;
|
||||||
|
phi: number;
|
||||||
|
orbitTime: MutableRefObject<number>;
|
||||||
|
simTime?: MutableRefObject<number>;
|
||||||
|
thetaDrift?: number;
|
||||||
|
paused?: boolean;
|
||||||
|
}
|
||||||
|
export declare function OrbitingObject({ radius, theta, phi, orbitTime, simTime, thetaDrift, paused }: OrbitingObjectProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export declare function PoissonShell({ radius }: {
|
||||||
|
radius: number;
|
||||||
|
}): import("react/jsx-runtime").JSX.Element;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
interface PulsingSphereProps {
|
||||||
|
radius: number;
|
||||||
|
paused?: boolean;
|
||||||
|
}
|
||||||
|
export declare function PulsingSphere({ radius, paused }: PulsingSphereProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { GlitchHostBridge, SceneViewMode } from '../types';
|
||||||
|
interface SamplingSystemProps {
|
||||||
|
complexity: number;
|
||||||
|
radius: number;
|
||||||
|
distance: number;
|
||||||
|
theta: number;
|
||||||
|
phi: number;
|
||||||
|
observationMode: 'orbit' | 'randomSphere';
|
||||||
|
allSectors: boolean;
|
||||||
|
cumulative: boolean;
|
||||||
|
K: number;
|
||||||
|
orbitRadius: number;
|
||||||
|
orbitVelocity: number;
|
||||||
|
thetaDrift: number;
|
||||||
|
thetaJitter: number;
|
||||||
|
phiJitter: number;
|
||||||
|
usePoisson: boolean;
|
||||||
|
speedup: number;
|
||||||
|
paused: boolean;
|
||||||
|
sceneView: SceneViewMode;
|
||||||
|
bottomInset?: number;
|
||||||
|
showNumericDiagnostics?: boolean;
|
||||||
|
compactNumericDiagnostics?: boolean;
|
||||||
|
host?: GlitchHostBridge;
|
||||||
|
centerDistribution?: boolean;
|
||||||
|
hideFlyingBars?: boolean;
|
||||||
|
}
|
||||||
|
export declare function SamplingSystem({ complexity, radius, distance, theta, phi, observationMode, allSectors, cumulative, K, orbitRadius, orbitVelocity, thetaDrift, thetaJitter, phiJitter, usePoisson, speedup, paused, sceneView, bottomInset, showNumericDiagnostics, compactNumericDiagnostics, host, centerDistribution, hideFlyingBars }: SamplingSystemProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
interface TargetDiscProps {
|
||||||
|
radius: number;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
export declare function TargetDisc({ radius, distance }: TargetDiscProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
interface VectorArrowProps {
|
||||||
|
vector: [number, number, number];
|
||||||
|
origin?: [number, number, number];
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
export declare function VectorArrow({ vector, origin, color }: VectorArrowProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
import './dev-theme.css';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user