mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(modality)!: SnapOtter 2.0 phase 3 modality framework: media/doc engines, pool routing, display modes (#218)
This commit is contained in:
@@ -30,6 +30,16 @@ MAX_SVG_SIZE_MB=0
|
||||
MAX_LOGO_SIZE_KB=2048
|
||||
MAX_SPLIT_GRID=100
|
||||
MAX_PDF_PAGES=0
|
||||
MAX_VIDEO_DURATION_S=0
|
||||
MAX_AUDIO_DURATION_S=0
|
||||
MAX_VIDEO_BITRATE_KBPS=0
|
||||
LIBREOFFICE_TIMEOUT_S=120
|
||||
# Engine binary overrides (default: $PATH lookup)
|
||||
# FFMPEG_PATH=
|
||||
# FFPROBE_PATH=
|
||||
# QPDF_PATH=
|
||||
# SOFFICE_PATH=
|
||||
# SNAPOTTER_HW_ACCEL= # nvenc|vaapi: hardware encoder family (default software)
|
||||
SESSION_DURATION_HOURS=168
|
||||
LOGIN_ATTEMPT_LIMIT=10
|
||||
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
|
||||
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"api_keys": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'Default API Key'"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"last_used_at": {
|
||||
"name": "last_used_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -65,120 +78,423 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"jobs": {
|
||||
"name": "jobs",
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"actor_username": {
|
||||
"name": "actor_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'queued'"
|
||||
"notNull": true
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"input_files": {
|
||||
"name": "input_files",
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"output_path": {
|
||||
"name": "output_path",
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"audit_log_actor_id_users_id_fk": {
|
||||
"name": "audit_log_actor_id_users_id_fk",
|
||||
"tableFrom": "audit_log",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["actor_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"public.jobs": {
|
||||
"name": "jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"tool_id": {
|
||||
"name": "tool_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pool": {
|
||||
"name": "pool",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "job_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"default": 0
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"input_refs": {
|
||||
"name": "input_refs",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"output_refs": {
|
||||
"name": "output_refs",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_in": {
|
||||
"name": "bytes_in",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_out": {
|
||||
"name": "bytes_out",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"duration_ms": {
|
||||
"name": "duration_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"jobs_created_at_idx": {
|
||||
"name": "jobs_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"jobs_status_idx": {
|
||||
"name": "jobs_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"jobs_user_id_users_id_fk": {
|
||||
"name": "jobs_user_id_users_id_fk",
|
||||
"tableFrom": "jobs",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pipelines": {
|
||||
"name": "pipelines",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pipelines_user_id_users_id_fk": {
|
||||
"name": "pipelines_user_id_users_id_fk",
|
||||
"tableFrom": "pipelines",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.roles": {
|
||||
"name": "roles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"default": "''"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_builtin": {
|
||||
"name": "is_builtin",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"roles_created_by_users_id_fk": {
|
||||
"name": "roles_created_by_users_id_fk",
|
||||
"tableFrom": "roles",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["created_by"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"roles_name_unique": {
|
||||
"name": "roles_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -195,115 +511,297 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"settings": {
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"teams_name_unique": {
|
||||
"name": "teams_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_files": {
|
||||
"name": "user_files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_name": {
|
||||
"name": "original_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stored_name": {
|
||||
"name": "stored_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"default": 1
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tool_chain": {
|
||||
"name": "tool_chain",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_files_user_id_users_id_fk": {
|
||||
"name": "user_files_user_id_users_id_fk",
|
||||
"tableFrom": "user_files",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"team": {
|
||||
"name": "team",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Default'"
|
||||
},
|
||||
"must_change_password": {
|
||||
"name": "must_change_password",
|
||||
"type": "integer",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"auth_provider": {
|
||||
"name": "auth_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'local'"
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": ["username"],
|
||||
"isUnique": true
|
||||
"notNull": true
|
||||
},
|
||||
"analytics_enabled": {
|
||||
"name": "analytics_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_shown_at": {
|
||||
"name": "analytics_consent_shown_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_remind_at": {
|
||||
"name": "analytics_consent_remind_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["username"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
"enums": {
|
||||
"public.job_status": {
|
||||
"name": "job_status",
|
||||
"schema": "public",
|
||||
"values": ["queued", "processing", "completed", "failed", "canceled"]
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,54 @@
|
||||
{
|
||||
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
|
||||
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458",
|
||||
"prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'Default API Key'"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_used_at": {
|
||||
"name": "last_used_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -78,423 +65,165 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"actor_username": {
|
||||
"name": "actor_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"audit_log_actor_id_users_id_fk": {
|
||||
"name": "audit_log_actor_id_users_id_fk",
|
||||
"tableFrom": "audit_log",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["actor_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.jobs": {
|
||||
"jobs": {
|
||||
"name": "jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tool_id": {
|
||||
"name": "tool_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pool": {
|
||||
"name": "pool",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "job_status",
|
||||
"typeSchema": "public",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "jsonb",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"input_refs": {
|
||||
"name": "input_refs",
|
||||
"type": "jsonb",
|
||||
"input_files": {
|
||||
"name": "input_files",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"output_refs": {
|
||||
"name": "output_refs",
|
||||
"type": "jsonb",
|
||||
"output_path": {
|
||||
"name": "output_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "jsonb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_in": {
|
||||
"name": "bytes_in",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_out": {
|
||||
"name": "bytes_out",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"duration_ms": {
|
||||
"name": "duration_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"jobs_created_at_idx": {
|
||||
"name": "jobs_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"jobs_status_idx": {
|
||||
"name": "jobs_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"jobs_user_id_users_id_fk": {
|
||||
"name": "jobs_user_id_users_id_fk",
|
||||
"tableFrom": "jobs",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.pipelines": {
|
||||
"pipelines": {
|
||||
"name": "pipelines",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"type": "jsonb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pipelines_user_id_users_id_fk": {
|
||||
"name": "pipelines_user_id_users_id_fk",
|
||||
"tableFrom": "pipelines",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.roles": {
|
||||
"name": "roles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_builtin": {
|
||||
"name": "is_builtin",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"roles_created_by_users_id_fk": {
|
||||
"name": "roles_created_by_users_id_fk",
|
||||
"tableFrom": "roles",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["created_by"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"roles_name_unique": {
|
||||
"name": "roles_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -511,297 +240,115 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.settings": {
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"teams_name_unique": {
|
||||
"name": "teams_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_files": {
|
||||
"name": "user_files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_name": {
|
||||
"name": "original_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stored_name": {
|
||||
"name": "stored_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tool_chain": {
|
||||
"name": "tool_chain",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_files_user_id_users_id_fk": {
|
||||
"name": "user_files_user_id_users_id_fk",
|
||||
"tableFrom": "user_files",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.users": {
|
||||
"users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"team": {
|
||||
"name": "team",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Default'"
|
||||
},
|
||||
"must_change_password": {
|
||||
"name": "must_change_password",
|
||||
"type": "boolean",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"auth_provider": {
|
||||
"name": "auth_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'local'"
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"analytics_enabled": {
|
||||
"name": "analytics_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_shown_at": {
|
||||
"name": "analytics_consent_shown_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_remind_at": {
|
||||
"name": "analytics_consent_remind_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["username"]
|
||||
"columns": ["username"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.job_status": {
|
||||
"name": "job_status",
|
||||
"schema": "public",
|
||||
"values": ["queued", "processing", "completed", "failed", "canceled"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
"@scalar/fastify-api-reference": "^1.57.5",
|
||||
"@sentry/node": "^10.55.0",
|
||||
"@snapotter/ai": "workspace:*",
|
||||
"@snapotter/doc-engine": "workspace:*",
|
||||
"@snapotter/enterprise": "workspace:*",
|
||||
"@snapotter/image-engine": "workspace:*",
|
||||
"@snapotter/media-engine": "workspace:*",
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
@@ -42,7 +44,6 @@
|
||||
"pdfkit": "^0.18.0",
|
||||
"pg": "^8.21.0",
|
||||
"pino-roll": "^4.0.0",
|
||||
"piscina": "^5.1.4",
|
||||
"playwright": "^1.60.0",
|
||||
"posthog-node": "^5.35.9",
|
||||
"potrace": "^2.1.8",
|
||||
|
||||
+4
-10
@@ -20,7 +20,7 @@ import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analyt
|
||||
import { shouldRunStartupCleanup } from "./lib/cleanup.js";
|
||||
import { buildCsp } from "./lib/csp.js";
|
||||
import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js";
|
||||
import { shutdownWorkerPool } from "./lib/worker-pool.js";
|
||||
|
||||
import { requirePermission } from "./permissions.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
@@ -507,16 +507,10 @@ async function shutdown(signal: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
await shutdownWorkerPool();
|
||||
console.log("Worker pool shut down");
|
||||
} catch (err) {
|
||||
console.error("Error shutting down worker pool:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
const { shutdownDispatcher } = await import("@snapotter/ai");
|
||||
const { shutdownDispatcher, shutdownDocsDispatcher } = await import("@snapotter/ai");
|
||||
shutdownDispatcher();
|
||||
console.log("Python dispatcher shut down");
|
||||
await shutdownDocsDispatcher();
|
||||
console.log("Python dispatchers shut down");
|
||||
} catch {
|
||||
// AI package may not be available
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { eq } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { putObject } from "../lib/object-storage.js";
|
||||
import { pdfFirstPagePreview, videoPosterPreview } from "../modality/preview.js";
|
||||
|
||||
// ── Content-type to extension map ──────────────────────────────
|
||||
|
||||
@@ -87,9 +88,9 @@ const BROWSER_PREVIEWABLE = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Generate a browser-previewable WebP thumbnail for formats that browsers
|
||||
* Generate a browser-previewable thumbnail for formats that browsers
|
||||
* cannot render in <img> tags. Writes to object storage under
|
||||
* `outputs/<jobId>/preview.webp`.
|
||||
* `outputs/<jobId>/preview.<ext>` (webp for images/video, png for PDF).
|
||||
*
|
||||
* Returns the object key on success, undefined when the format is already
|
||||
* previewable or when generation fails (non-fatal).
|
||||
@@ -100,6 +101,24 @@ export async function generatePreview(
|
||||
jobId: string,
|
||||
fallbackInput?: Buffer,
|
||||
): Promise<string | undefined> {
|
||||
// Per-modality dispatch (before the image logic)
|
||||
if (contentType.startsWith("video/")) {
|
||||
const poster = await videoPosterPreview(buffer);
|
||||
if (!poster) return undefined;
|
||||
const key = `outputs/${jobId}/preview.webp`;
|
||||
await putObject(key, poster);
|
||||
return key;
|
||||
}
|
||||
if (contentType.startsWith("audio/")) return undefined; // no preview (spec 4.5)
|
||||
if (contentType === "application/pdf") {
|
||||
const page = await pdfFirstPagePreview(buffer);
|
||||
if (!page) return undefined;
|
||||
const key = `outputs/${jobId}/preview.png`;
|
||||
await putObject(key, page);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Image logic unchanged below
|
||||
if (BROWSER_PREVIEWABLE.has(contentType)) return undefined;
|
||||
|
||||
const key = `outputs/${jobId}/preview.webp`;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* are deferred until the final attempt so intermediate retries stay
|
||||
* invisible to the client.
|
||||
*/
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type Job, UnrecoverableError, Worker } from "bullmq";
|
||||
@@ -32,7 +32,11 @@ import { resolveConcurrency } from "../lib/env.js";
|
||||
import { jobDuration, jobsTotal } from "../lib/metrics.js";
|
||||
import { getObjectBuffer, putObject } from "../lib/object-storage.js";
|
||||
import { publishEphemeral, updateSingleFileProgress } from "../routes/progress.js";
|
||||
import { getToolConfig, type ToolProcessCtx } from "../routes/tool-factory.js";
|
||||
import {
|
||||
getToolConfig,
|
||||
type ToolProcessCtx,
|
||||
type ToolProcessInputV2,
|
||||
} from "../routes/tool-factory.js";
|
||||
import { hasAiJobHandler, runAiToolJob } from "./ai-handlers.js";
|
||||
import { recordChildOutcome } from "./batch-progress.js";
|
||||
import { registerCancelable, unregisterCancelable } from "./cancel.js";
|
||||
@@ -80,7 +84,8 @@ export function buildLegacyResultPayload(
|
||||
processedSize: jobResult.processedSize,
|
||||
};
|
||||
if (jobResult.previewRef) {
|
||||
payload.previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
||||
const previewFilename = jobResult.previewRef.split("/").pop();
|
||||
payload.previewUrl = `/api/v1/download/${jobId}/${previewFilename}`;
|
||||
}
|
||||
if (jobResult.savedFileId) {
|
||||
payload.savedFileId = jobResult.savedFileId;
|
||||
@@ -123,8 +128,18 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
})
|
||||
.where(eq(schema.jobs.id, jobId));
|
||||
|
||||
// Load input from object storage
|
||||
const inputBuffer = await getObjectBuffer(data.inputRefs[0]);
|
||||
// Load all input refs from object storage. The primary input keeps
|
||||
// the client-facing filename; secondary inputs derive filenames from
|
||||
// their ref basenames.
|
||||
const inputs: ToolProcessInputV2[] = await Promise.all(
|
||||
data.inputRefs.map(async (ref) => ({
|
||||
ref,
|
||||
buffer: await getObjectBuffer(ref),
|
||||
filename: ref.split("/").slice(2).join("/") || data.filename,
|
||||
})),
|
||||
);
|
||||
inputs[0].filename = data.filename; // primary keeps the client-facing name
|
||||
const inputBuffer = inputs[0].buffer; // existing metrics/size/preview paths
|
||||
|
||||
// Progress reporter: emits both Redis pub/sub and BullMQ job progress
|
||||
const progressJobId = data.clientJobId ?? jobId;
|
||||
@@ -161,10 +176,45 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
} else {
|
||||
const config = getToolConfig(data.toolId);
|
||||
if (!config) throw new Error(`No tool config for ${data.toolId}`);
|
||||
const result = await config.process(inputBuffer, data.settings, data.filename, ctx);
|
||||
resultBuffer = result.buffer;
|
||||
|
||||
// Use the resolved v2 process function (adapter or native)
|
||||
if (!config.processV2) throw new Error(`No processV2 for ${data.toolId}`);
|
||||
const result = await config.processV2({
|
||||
inputs,
|
||||
settings: data.settings,
|
||||
scratchDir,
|
||||
signal,
|
||||
report,
|
||||
});
|
||||
|
||||
// Resolve buffer OR scratchPath for the primary output
|
||||
if (result.buffer) {
|
||||
resultBuffer = result.buffer;
|
||||
} else if (result.scratchPath) {
|
||||
resultBuffer = await readFile(result.scratchPath);
|
||||
} else {
|
||||
throw new Error(`Tool ${data.toolId} returned neither buffer nor scratchPath`);
|
||||
}
|
||||
resultFilename = result.filename;
|
||||
resultContentType = result.contentType;
|
||||
resultPayload = result.resultPayload;
|
||||
|
||||
// Resolve extra outputs with the same buffer/scratchPath duality
|
||||
if (result.extraOutputs) {
|
||||
extraOutputs = await Promise.all(
|
||||
result.extraOutputs.map(async (extra) => {
|
||||
let buf: Buffer;
|
||||
if (extra.buffer) {
|
||||
buf = extra.buffer;
|
||||
} else if (extra.scratchPath) {
|
||||
buf = await readFile(extra.scratchPath);
|
||||
} else {
|
||||
throw new Error(`Extra output "${extra.name}" has neither buffer nor scratchPath`);
|
||||
}
|
||||
return { name: extra.name, buffer: buf, contentType: extra.contentType };
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build output name with tool suffix and extension fixup
|
||||
|
||||
@@ -52,6 +52,10 @@ const envSchema = z
|
||||
MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000),
|
||||
MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10),
|
||||
MAX_PDF_PAGES: z.coerce.number().default(0),
|
||||
MAX_VIDEO_DURATION_S: z.coerce.number().default(0),
|
||||
MAX_AUDIO_DURATION_S: z.coerce.number().default(0),
|
||||
MAX_VIDEO_BITRATE_KBPS: z.coerce.number().default(0),
|
||||
LIBREOFFICE_TIMEOUT_S: z.coerce.number().default(120),
|
||||
SESSION_DURATION_HOURS: z.coerce.number().default(168),
|
||||
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
|
||||
TRUST_PROXY: z
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Piscina worker that executes image tool processing in a worker thread.
|
||||
*
|
||||
* On first call, it imports all tool registration modules using a mock
|
||||
* Fastify instance (only the registry-populating side effects are needed,
|
||||
* not the HTTP route registrations). Subsequent calls reuse the populated
|
||||
* registry for O(1) lookup.
|
||||
*/
|
||||
import { autoOrient } from "./auto-orient.js";
|
||||
|
||||
export interface WorkerInput {
|
||||
toolId: string;
|
||||
inputBuffer: Buffer;
|
||||
settings: unknown;
|
||||
filename: string;
|
||||
inputFormat?: string;
|
||||
}
|
||||
|
||||
export interface WorkerOutput {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
let registryReady = false;
|
||||
|
||||
async function ensureRegistry(): Promise<void> {
|
||||
if (registryReady) return;
|
||||
|
||||
// Create a minimal mock that satisfies the register functions.
|
||||
// createToolRoute calls app.post() (no-op here) and toolRegistry.set() (the part we want).
|
||||
// AI tools also call app.post() and registerToolProcessFn() (also populates the registry).
|
||||
const mockApp = {
|
||||
post: () => {},
|
||||
get: () => {},
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
};
|
||||
|
||||
const { registerToolRoutes } = await import("../routes/tools/index.js");
|
||||
await registerToolRoutes(mockApp as never);
|
||||
registryReady = true;
|
||||
}
|
||||
|
||||
export default async function processInWorker(input: WorkerInput): Promise<WorkerOutput> {
|
||||
await ensureRegistry();
|
||||
|
||||
const { getToolConfig } = await import("../routes/tool-factory.js");
|
||||
const config = getToolConfig(input.toolId);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`Tool "${input.toolId}" not found in worker registry`);
|
||||
}
|
||||
|
||||
const buf = Buffer.from(input.inputBuffer);
|
||||
const oriented = input.inputFormat === "svg" ? buf : await autoOrient(buf);
|
||||
const result = await config.process(oriented, input.settings, input.filename);
|
||||
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
filename: result.filename,
|
||||
contentType: result.contentType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MODALITY_POOL, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
|
||||
import { hasAiJobHandler } from "../jobs/ai-handlers.js";
|
||||
import type { Pool } from "../jobs/types.js";
|
||||
|
||||
/** ai handler/bundle wins; else the tool's modality decides (spec 4.5). */
|
||||
export function resolveToolPool(toolId: string): Pool {
|
||||
if (hasAiJobHandler(toolId) || TOOL_BUNDLE_MAP[toolId]) return "ai";
|
||||
const tool = TOOLS.find((t) => t.id === toolId);
|
||||
return tool ? MODALITY_POOL[tool.modality] : "image";
|
||||
}
|
||||
|
||||
export function shouldSkipSyncWindow(executionHint: "fast" | "long" | undefined): boolean {
|
||||
return executionHint === "long";
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Worker pool for offloading CPU-bound image processing from the main event loop.
|
||||
*
|
||||
* Uses Piscina (backed by worker_threads) so Sharp operations don't block
|
||||
* HTTP request handling, SSE streams, or health checks.
|
||||
*/
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Piscina from "piscina";
|
||||
import { loadEnv, resolveWorkerThreads } from "./env.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const maxThreads = resolveWorkerThreads(loadEnv());
|
||||
|
||||
let pool: Piscina | null = null;
|
||||
|
||||
export function getWorkerPool(): Piscina {
|
||||
if (!pool) {
|
||||
pool = new Piscina({
|
||||
filename: resolve(__dirname, "image-worker.ts"),
|
||||
// Inherit tsx loader flags from the main process so .ts files work in workers
|
||||
execArgv: [...process.execArgv],
|
||||
maxThreads,
|
||||
idleTimeout: 30000,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function shutdownWorkerPool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.destroy();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared types for modality input handlers. Lives in its own file to
|
||||
* break the import cycle between input-handler.ts (registry) and the
|
||||
* per-modality implementations that reference these types.
|
||||
*/
|
||||
|
||||
export class InputValidationError extends Error {
|
||||
statusCode: number;
|
||||
details?: string;
|
||||
constructor(message: string, statusCode = 400, details?: string) {
|
||||
super(message);
|
||||
this.name = "InputValidationError";
|
||||
this.statusCode = statusCode;
|
||||
if (details !== undefined) this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreparedInput {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modality-specific upload validation/normalization (spec 4.5). Throws
|
||||
* InputValidationError (400) on rejection. The factory owns storage and
|
||||
* enqueueing; handlers own format logic only.
|
||||
*/
|
||||
export interface InputHandler {
|
||||
prepare(raw: Buffer, filename: string, opts: { scratchDir: string }): Promise<PreparedInput>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { qpdfAvailable, qpdfCheck, qpdfPageCount } from "@snapotter/doc-engine";
|
||||
import { env } from "../config.js";
|
||||
import { type InputHandler, InputValidationError, type PreparedInput } from "./contract.js";
|
||||
|
||||
const ZIP_MAGIC = Buffer.from("PK");
|
||||
|
||||
/**
|
||||
* Documents: header magic + qpdf structural check + page caps for PDFs
|
||||
* (spec 4.5/4.7). Office/EPUB containers get a zip-magic sanity check in
|
||||
* phase 3; deep validation happens when conversion engines consume them.
|
||||
* The "file" modality (csv/json/...) shares this handler as a passthrough.
|
||||
*/
|
||||
export class DocumentInputHandler implements InputHandler {
|
||||
async prepare(
|
||||
raw: Buffer,
|
||||
filename: string,
|
||||
opts: { scratchDir: string },
|
||||
): Promise<PreparedInput> {
|
||||
if (raw.length === 0) throw new InputValidationError("Empty file");
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".pdf")) {
|
||||
if (raw.subarray(0, 5).toString() !== "%PDF-") {
|
||||
throw new InputValidationError("File does not start with a PDF header");
|
||||
}
|
||||
if (qpdfAvailable()) {
|
||||
const dir = join(opts.scratchDir, `qpdf-${randomUUID()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const p = join(dir, "input.pdf");
|
||||
try {
|
||||
await writeFile(p, raw);
|
||||
try {
|
||||
await qpdfCheck(p);
|
||||
} catch (err) {
|
||||
throw new InputValidationError(
|
||||
`Damaged PDF: ${err instanceof Error ? err.message.slice(0, 300) : "structural check failed"}`,
|
||||
);
|
||||
}
|
||||
if (env.MAX_PDF_PAGES > 0) {
|
||||
const pages = await qpdfPageCount(p);
|
||||
if (pages > env.MAX_PDF_PAGES) {
|
||||
throw new InputValidationError(
|
||||
`PDF has ${pages} pages, exceeding the maximum of ${env.MAX_PDF_PAGES}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
[".docx", ".xlsx", ".pptx", ".epub", ".odt", ".ods", ".odp"].some((e) => lower.endsWith(e))
|
||||
) {
|
||||
if (!raw.subarray(0, 2).equals(ZIP_MAGIC)) {
|
||||
throw new InputValidationError("File is not a valid Office/EPUB container");
|
||||
}
|
||||
}
|
||||
return { buffer: raw, filename };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import sharp from "sharp";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { stripInternalPaths } from "../lib/errors.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { decodeAnyFormat, decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { type InputHandler, InputValidationError, type PreparedInput } from "./contract.js";
|
||||
|
||||
/**
|
||||
* Image input handler: validateImageBuffer, HEIC decode, CLI decode,
|
||||
* SVG sanitize, AVIF probe fallback, autoOrient. Extracted verbatim
|
||||
* from the tool-factory validation/decode chain.
|
||||
*/
|
||||
export class ImageInputHandler implements InputHandler {
|
||||
async prepare(
|
||||
raw: Buffer,
|
||||
originalFilename: string,
|
||||
_opts: { scratchDir: string },
|
||||
): Promise<PreparedInput> {
|
||||
let fileBuffer = raw;
|
||||
let name = originalFilename;
|
||||
|
||||
// Validate the uploaded image
|
||||
const validation = await validateImageBuffer(fileBuffer, name);
|
||||
if (!validation.valid) {
|
||||
throw new InputValidationError(`Invalid image: ${validation.reason}`);
|
||||
}
|
||||
|
||||
// Decode HEIC/HEIF input via system heif-dec (Sharp's bundled libheif
|
||||
// lacks the HEVC decoder needed for iPhone photos).
|
||||
// The decoded buffer is PNG, so update the filename extension to match.
|
||||
const isHeif = validation.format === "heif";
|
||||
if (isHeif) {
|
||||
try {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = name.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) name = `${name.slice(0, -ext.length)}.png`;
|
||||
} catch (err) {
|
||||
throw new InputValidationError(
|
||||
"Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||
422,
|
||||
stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools.
|
||||
// The decoded buffer is PNG, so update the filename extension to match.
|
||||
// Pass the original file extension so RAW decoder can use the correct
|
||||
// temp file suffix (e.g. .cr3, .nef) for format identification.
|
||||
if (needsCliDecode(validation.format)) {
|
||||
try {
|
||||
const fileExt = name.split(".").pop()?.toLowerCase();
|
||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
||||
} catch {
|
||||
try {
|
||||
await sharp(fileBuffer).metadata();
|
||||
} catch (err) {
|
||||
throw new InputValidationError(
|
||||
`Failed to decode ${validation.format.toUpperCase()} file`,
|
||||
422,
|
||||
stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
const ext = name.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) name = `${name.slice(0, -ext.length)}.png`;
|
||||
}
|
||||
|
||||
// Sanitize SVG input to prevent XXE, SSRF, and script injection
|
||||
const isSvg = validation.format === "svg";
|
||||
if (isSvg) {
|
||||
try {
|
||||
fileBuffer = decompressSvgz(fileBuffer);
|
||||
fileBuffer = sanitizeSvg(fileBuffer);
|
||||
} catch (err) {
|
||||
throw new InputValidationError(err instanceof Error ? err.message : "Invalid SVG");
|
||||
}
|
||||
}
|
||||
|
||||
// AVIF can pass metadata validation but fail pixel decode when
|
||||
// Sharp's bundled libheif lacks support for the bitstream version.
|
||||
// A 1x1 resize forces a minimal pixel decode to catch this early.
|
||||
if (validation.format === "avif") {
|
||||
try {
|
||||
await sharp(fileBuffer).resize(1).raw().toBuffer();
|
||||
} catch {
|
||||
try {
|
||||
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
|
||||
const ext = name.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) name = `${name.slice(0, -ext.length)}.png`;
|
||||
} catch (fallbackErr) {
|
||||
throw new InputValidationError(
|
||||
"Failed to decode AVIF file",
|
||||
422,
|
||||
stripInternalPaths(
|
||||
fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-orient non-SVG images: physically rotate pixels to match
|
||||
// the EXIF orientation tag so the worker sees upright pixels.
|
||||
if (!isSvg) {
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: fileBuffer,
|
||||
filename: name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Modality } from "@snapotter/shared";
|
||||
import type { InputHandler } from "./contract.js";
|
||||
import { DocumentInputHandler } from "./document-input.js";
|
||||
import { ImageInputHandler } from "./image-input.js";
|
||||
import { MediaInputHandler } from "./media-input.js";
|
||||
|
||||
const HANDLERS: Record<Modality, InputHandler> = {
|
||||
image: new ImageInputHandler(),
|
||||
video: new MediaInputHandler("video"),
|
||||
audio: new MediaInputHandler("audio"),
|
||||
document: new DocumentInputHandler(),
|
||||
file: new DocumentInputHandler(),
|
||||
};
|
||||
|
||||
export function inputHandlerFor(modality: Modality): InputHandler {
|
||||
return HANDLERS[modality];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import { env } from "../config.js";
|
||||
import { type InputHandler, InputValidationError, type PreparedInput } from "./contract.js";
|
||||
|
||||
/**
|
||||
* Video/audio validation via capped ffprobe (spec 4.7). ffprobe needs a real
|
||||
* file (mp4 moov atoms may trail), so the buffer lands in the scratch dir.
|
||||
*/
|
||||
export class MediaInputHandler implements InputHandler {
|
||||
constructor(private kind: "video" | "audio") {}
|
||||
|
||||
async prepare(
|
||||
raw: Buffer,
|
||||
filename: string,
|
||||
opts: { scratchDir: string },
|
||||
): Promise<PreparedInput> {
|
||||
const probeDir = join(opts.scratchDir, `probe-${randomUUID()}`);
|
||||
await mkdir(probeDir, { recursive: true });
|
||||
const probePath = join(probeDir, "input");
|
||||
try {
|
||||
await writeFile(probePath, raw);
|
||||
let info: Awaited<ReturnType<typeof probeMedia>>;
|
||||
try {
|
||||
info = await probeMedia(probePath);
|
||||
} catch (err) {
|
||||
throw new InputValidationError(
|
||||
`Unrecognized ${this.kind} file: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
const hasVideo = info.streams.some((s) => s.type === "video");
|
||||
const hasAudio = info.streams.some((s) => s.type === "audio");
|
||||
if (this.kind === "video" && !hasVideo) {
|
||||
throw new InputValidationError("File contains no video stream");
|
||||
}
|
||||
if (this.kind === "audio" && !hasAudio) {
|
||||
throw new InputValidationError("File contains no audio stream");
|
||||
}
|
||||
const durationCap =
|
||||
this.kind === "video" ? env.MAX_VIDEO_DURATION_S : env.MAX_AUDIO_DURATION_S;
|
||||
if (durationCap > 0 && info.durationS !== null && info.durationS > durationCap) {
|
||||
throw new InputValidationError(
|
||||
`Duration ${Math.round(info.durationS)}s exceeds the maximum of ${durationCap}s`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.kind === "video" &&
|
||||
env.MAX_VIDEO_BITRATE_KBPS > 0 &&
|
||||
info.bitrateKbps !== null &&
|
||||
info.bitrateKbps > env.MAX_VIDEO_BITRATE_KBPS
|
||||
) {
|
||||
throw new InputValidationError(
|
||||
`Bitrate ${info.bitrateKbps}kbps exceeds the maximum of ${env.MAX_VIDEO_BITRATE_KBPS}kbps`,
|
||||
);
|
||||
}
|
||||
return { buffer: raw, filename };
|
||||
} finally {
|
||||
await rm(probeDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveGs } from "@snapotter/doc-engine";
|
||||
import { ffmpegAvailable, runFfmpeg } from "@snapotter/media-engine";
|
||||
|
||||
const PREVIEW_WIDTH = 480;
|
||||
|
||||
/** Video poster frame as WebP, or null when ffmpeg is unavailable/fails. */
|
||||
export async function videoPosterPreview(buffer: Buffer): Promise<Buffer | null> {
|
||||
if (!ffmpegAvailable()) return null;
|
||||
const dir = join(tmpdir(), "snapotter-scratch", `preview-${randomUUID()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
try {
|
||||
const input = join(dir, "in");
|
||||
const output = join(dir, "poster.webp");
|
||||
await writeFile(input, buffer);
|
||||
await runFfmpeg(
|
||||
["-ss", "0", "-i", input, "-frames:v", "1", "-vf", `scale=${PREVIEW_WIDTH}:-2`, output],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
return await readFile(output);
|
||||
} catch {
|
||||
return null; // previews must never fail the job
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** First PDF page rendered to PNG via ghostscript, or null. */
|
||||
export async function pdfFirstPagePreview(buffer: Buffer): Promise<Buffer | null> {
|
||||
const gs = resolveGs();
|
||||
if (!gs) return null;
|
||||
const dir = join(tmpdir(), "snapotter-scratch", `preview-${randomUUID()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
try {
|
||||
const input = join(dir, "in.pdf");
|
||||
const output = join(dir, "page1.png");
|
||||
await writeFile(input, buffer);
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
gs,
|
||||
[
|
||||
"-dSAFER",
|
||||
"-dBATCH",
|
||||
"-dNOPAUSE",
|
||||
"-dFirstPage=1",
|
||||
"-dLastPage=1",
|
||||
"-sDEVICE=png16m",
|
||||
"-r96",
|
||||
`-sOutputFile=${output}`,
|
||||
input,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
let err = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("ghostscript preview timed out"));
|
||||
}, 30_000);
|
||||
child.stderr.on("data", (c: Buffer) => {
|
||||
err = (err + c.toString("utf8")).slice(-2048);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`gs exited ${code ?? signal}: ${err}`));
|
||||
});
|
||||
});
|
||||
return await readFile(output);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { hasAiJobHandler } from "../jobs/ai-handlers.js";
|
||||
import { recordChildOutcome } from "../jobs/batch-progress.js";
|
||||
import { getFlowProducer, waitForJob } from "../jobs/enqueue.js";
|
||||
import { type Pool, queueName, type ToolJobData } from "../jobs/types.js";
|
||||
@@ -30,6 +29,7 @@ import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import { getObjectStream, putObject } from "../lib/object-storage.js";
|
||||
import { resolveToolPool } from "../lib/pool.js";
|
||||
import { getAuthUser } from "../plugins/auth.js";
|
||||
import { updateJobProgress } from "./progress.js";
|
||||
import { getToolConfig } from "./tool-factory.js";
|
||||
@@ -135,7 +135,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ── Create job ID and initial progress ────────────────────────
|
||||
const parentId = clientJobId || randomUUID();
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const pool: Pool = hasAiJobHandler(toolId) || TOOL_BUNDLE_MAP[toolId] ? "ai" : "image";
|
||||
const pool: Pool = resolveToolPool(toolId);
|
||||
|
||||
// Insert the parent row BEFORE updateJobProgress, because the
|
||||
// progress persist layer does a check-then-insert that races
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { hasAiJobHandler } from "../jobs/ai-handlers.js";
|
||||
import { recordChildOutcome } from "../jobs/batch-progress.js";
|
||||
import { getFlowProducer, waitForJob } from "../jobs/enqueue.js";
|
||||
import { type Pool, queueName, type ToolJobData } from "../jobs/types.js";
|
||||
@@ -30,6 +29,7 @@ import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import { getObjectStream, putObject } from "../lib/object-storage.js";
|
||||
import { resolveToolPool } from "../lib/pool.js";
|
||||
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { hasEffectivePermission } from "../permissions.js";
|
||||
import { getAuthUser, requireAuth } from "../plugins/auth.js";
|
||||
@@ -64,11 +64,6 @@ const savePipelineSchema = z.object({
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function resolvePool(toolId: string): Pool {
|
||||
if (hasAiJobHandler(toolId) || TOOL_BUNDLE_MAP[toolId]) return "ai";
|
||||
return "image";
|
||||
}
|
||||
|
||||
interface ParsedStep {
|
||||
toolId: string;
|
||||
resolvedToolId: string;
|
||||
@@ -335,7 +330,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
toolId: step.toolId,
|
||||
resolvedToolId,
|
||||
parsedSettings: settingsResult.data,
|
||||
pool: resolvePool(resolvedToolId),
|
||||
pool: resolveToolPool(resolvedToolId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -718,7 +713,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
toolId: step.toolId,
|
||||
resolvedToolId,
|
||||
parsedSettings: settingsResult.data,
|
||||
pool: resolvePool(resolvedToolId),
|
||||
pool: resolveToolPool(resolvedToolId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+196
-188
@@ -1,20 +1,20 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import type { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { enqueueToolJob, waitForJob } from "../jobs/enqueue.js";
|
||||
import { trackEvent } from "../lib/analytics.js";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../lib/errors.js";
|
||||
import { isToolInstalled } from "../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { decodeAnyFormat, decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import { getObjectBuffer, putObject } from "../lib/object-storage.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js";
|
||||
import { receiveUpload } from "../lib/upload-stream.js";
|
||||
import { InputValidationError } from "../modality/contract.js";
|
||||
import { inputHandlerFor } from "../modality/input-handler.js";
|
||||
import { getAuthUser } from "../plugins/auth.js";
|
||||
import { updateSingleFileProgress } from "./progress.js";
|
||||
|
||||
@@ -25,6 +25,41 @@ export interface ToolProcessCtx {
|
||||
report: (percent: number, stage?: string) => void;
|
||||
}
|
||||
|
||||
// ── V2 process contract (ref-based, multi-input) ──────────────
|
||||
|
||||
export interface ToolProcessInputV2 {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
ref: string;
|
||||
}
|
||||
|
||||
export interface ToolProcessCtxV2 {
|
||||
inputs: ToolProcessInputV2[];
|
||||
settings: unknown;
|
||||
scratchDir: string;
|
||||
signal: AbortSignal;
|
||||
report: (percent: number, stage?: string) => void;
|
||||
}
|
||||
|
||||
export interface ToolProcessResultV2 {
|
||||
/** Exactly one of buffer | scratchPath must be set. */
|
||||
buffer?: Buffer;
|
||||
scratchPath?: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
resultPayload?: Record<string, unknown>;
|
||||
extraOutputs?: Array<{
|
||||
name: string;
|
||||
buffer?: Buffer;
|
||||
scratchPath?: string;
|
||||
contentType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ToolProcessV2 = (ctx: ToolProcessCtxV2) => Promise<ToolProcessResultV2>;
|
||||
|
||||
// ── Tool route config ─────────────────────────────────────────
|
||||
|
||||
export interface ToolRouteConfig<T> {
|
||||
/** Unique tool identifier, used as the URL path segment. */
|
||||
toolId: string;
|
||||
@@ -37,6 +72,8 @@ export interface ToolRouteConfig<T> {
|
||||
filename: string,
|
||||
ctx?: ToolProcessCtx,
|
||||
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
|
||||
/** Optional v2 process function. When set, the worker calls this instead of the legacy process. */
|
||||
processV2?: ToolProcessV2;
|
||||
}
|
||||
|
||||
/** Type-erased config stored in the registry (settings type is widened to avoid variance issues). */
|
||||
@@ -49,6 +86,26 @@ export interface AnyToolRouteConfig {
|
||||
filename: string,
|
||||
ctx?: ToolProcessCtx,
|
||||
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
|
||||
processV2?: ToolProcessV2;
|
||||
}
|
||||
|
||||
// ── Legacy adapter ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wraps a legacy process function as a ToolProcessV2. The first input
|
||||
* is forwarded as the primary buffer/filename; extra inputs are ignored
|
||||
* (legacy tools accept only one input).
|
||||
*/
|
||||
function adaptLegacyProcess(config: AnyToolRouteConfig): ToolProcessV2 {
|
||||
return async (ctx) => {
|
||||
const primary = ctx.inputs[0];
|
||||
const result = await config.process(primary.buffer, ctx.settings, primary.filename, {
|
||||
signal: ctx.signal,
|
||||
scratchDir: ctx.scratchDir,
|
||||
report: ctx.report,
|
||||
});
|
||||
return { buffer: result.buffer, filename: result.filename, contentType: result.contentType };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,9 +132,13 @@ export function getRegisteredToolIds(): string[] {
|
||||
* Register a tool's process function in the pipeline/batch registry
|
||||
* without creating an HTTP route. Use this for tools that have their
|
||||
* own custom HTTP route but should still be usable in pipelines.
|
||||
*
|
||||
* Resolves processV2: uses the config's processV2 when provided,
|
||||
* otherwise wraps the legacy process function via adaptLegacyProcess.
|
||||
*/
|
||||
export function registerToolProcessFn(config: AnyToolRouteConfig): void {
|
||||
toolRegistry.set(config.toolId, config);
|
||||
const resolved = { ...config, processV2: config.processV2 ?? adaptLegacyProcess(config) };
|
||||
toolRegistry.set(config.toolId, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +157,14 @@ export function registerToolProcessFn(config: AnyToolRouteConfig): void {
|
||||
* - Response formatting (legacy envelope)
|
||||
*/
|
||||
export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig<T>): void {
|
||||
// Register in the tool registry for batch processing (cast to type-erased form)
|
||||
toolRegistry.set(config.toolId, config as AnyToolRouteConfig);
|
||||
// Register a resolved copy in the tool registry for batch processing.
|
||||
// Spread avoids mutating the caller's config object.
|
||||
const erased = config as AnyToolRouteConfig;
|
||||
const resolved: AnyToolRouteConfig = {
|
||||
...erased,
|
||||
processV2: erased.processV2 ?? adaptLegacyProcess(erased),
|
||||
};
|
||||
toolRegistry.set(config.toolId, resolved);
|
||||
|
||||
app.post(
|
||||
`/api/v1/tools/${config.toolId}`,
|
||||
@@ -181,203 +248,144 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
|
||||
reportProgress(5, "Validating...");
|
||||
|
||||
// Validate the uploaded image
|
||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||
if (!validation.valid) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||
}
|
||||
// Resolve the tool's modality (default "image" for registry-only test tools)
|
||||
const toolMeta = TOOLS.find((t) => t.id === config.toolId);
|
||||
const modality = toolMeta?.modality ?? "image";
|
||||
|
||||
// Decode HEIC/HEIF input via system heif-dec (Sharp's bundled libheif
|
||||
// lacks the HEVC decoder needed for iPhone photos).
|
||||
// The decoded buffer is PNG, so update the filename extension to match.
|
||||
const isHeif = validation.format === "heif";
|
||||
if (isHeif) {
|
||||
reportProgress(10, "Decoding HEIC...");
|
||||
try {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
} catch (err) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools.
|
||||
// The decoded buffer is PNG, so update the filename extension to match.
|
||||
// Pass the original file extension so RAW decoder can use the correct
|
||||
// temp file suffix (e.g. .cr3, .nef) for format identification.
|
||||
if (needsCliDecode(validation.format)) {
|
||||
reportProgress(10, "Decoding...");
|
||||
try {
|
||||
const fileExt = filename.split(".").pop()?.toLowerCase();
|
||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
||||
} catch {
|
||||
try {
|
||||
await sharp(fileBuffer).metadata();
|
||||
} catch (err) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(422).send({
|
||||
error: `Failed to decode ${validation.format.toUpperCase()} file`,
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
}
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
}
|
||||
|
||||
// Sanitize SVG input to prevent XXE, SSRF, and script injection
|
||||
const isSvg = validation.format === "svg";
|
||||
if (isSvg) {
|
||||
try {
|
||||
fileBuffer = decompressSvgz(fileBuffer);
|
||||
fileBuffer = sanitizeSvg(fileBuffer);
|
||||
} catch (err) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(400).send({
|
||||
error: err instanceof Error ? err.message : "Invalid SVG",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// AVIF can pass metadata validation but fail pixel decode when
|
||||
// Sharp's bundled libheif lacks support for the bitstream version.
|
||||
// A 1x1 resize forces a minimal pixel decode to catch this early.
|
||||
if (validation.format === "avif") {
|
||||
try {
|
||||
await sharp(fileBuffer).resize(1).raw().toBuffer();
|
||||
} catch {
|
||||
try {
|
||||
reportProgress(10, "Decoding...");
|
||||
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
} catch (fallbackErr) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode AVIF file",
|
||||
details: stripInternalPaths(
|
||||
fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-orient non-SVG images: physically rotate pixels to match
|
||||
// the EXIF orientation tag so the worker sees upright pixels.
|
||||
if (!isSvg) {
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
}
|
||||
|
||||
reportProgress(15, "Preparing...");
|
||||
|
||||
// Parse and validate settings
|
||||
if (settingsRaw && settingsRaw.length > 65536) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(400).send({ error: "Settings payload too large (max 64KB)" });
|
||||
}
|
||||
let settings: T;
|
||||
// Per-request scratch dir for handlers that need temp files
|
||||
const scratchDir = join(tmpdir(), "snapotter-scratch", jobId);
|
||||
await mkdir(scratchDir, { recursive: true });
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = config.settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
// Modality-specific input validation and normalization
|
||||
try {
|
||||
const prepared = await inputHandlerFor(modality).prepare(fileBuffer, filename, {
|
||||
scratchDir,
|
||||
});
|
||||
fileBuffer = prepared.buffer;
|
||||
filename = prepared.filename;
|
||||
} catch (err) {
|
||||
if (err instanceof InputValidationError) {
|
||||
const body: Record<string, string> = { error: err.message };
|
||||
if (err.details) body.details = err.details;
|
||||
return reply.status(err.statusCode).send(body);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
reportProgress(15, "Preparing...");
|
||||
|
||||
// Parse and validate settings
|
||||
if (settingsRaw && settingsRaw.length > 65536) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(result.error.issues),
|
||||
return reply.status(400).send({ error: "Settings payload too large (max 64KB)" });
|
||||
}
|
||||
let settings: T;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = config.settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
// Guard: check if the tool's AI feature bundle is installed
|
||||
const bundleId = TOOL_BUNDLE_MAP[config.toolId];
|
||||
if (bundleId && !isToolInstalled(config.toolId)) {
|
||||
const bundle = getBundleForTool(config.toolId);
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: bundleId,
|
||||
featureName: bundle?.name ?? bundleId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
// Guard: check if the tool's AI feature bundle is installed
|
||||
const bundleId = TOOL_BUNDLE_MAP[config.toolId];
|
||||
if (bundleId && !isToolInstalled(config.toolId)) {
|
||||
const bundle = getBundleForTool(config.toolId);
|
||||
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: bundleId,
|
||||
featureName: bundle?.name ?? bundleId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
// If decode/orient transformed the buffer or changed the filename,
|
||||
// write the final version so the worker processes the correct data.
|
||||
// Skip re-upload when the buffer is reference-identical to the
|
||||
// originally streamed bytes and the filename hasn't changed.
|
||||
const decodedName = filename;
|
||||
const decodedKey = `uploads/${jobId}/${decodedName}`;
|
||||
if (decodedKey !== inputKey) {
|
||||
await putObject(decodedKey, fileBuffer);
|
||||
inputKey = decodedKey;
|
||||
} else if (fileBuffer !== originalBuffer) {
|
||||
await putObject(inputKey, fileBuffer);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const pool = resolveToolPool(config.toolId);
|
||||
|
||||
// Enqueue for the BullMQ worker
|
||||
await enqueueToolJob({
|
||||
jobId,
|
||||
toolId: config.toolId,
|
||||
userId: getAuthUser(request)?.id ?? null,
|
||||
pool,
|
||||
inputRefs: [inputKey],
|
||||
filename,
|
||||
settings,
|
||||
fileId: fileId ?? undefined,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
kind: "tool",
|
||||
});
|
||||
}
|
||||
|
||||
// If decode/orient transformed the buffer or changed the filename,
|
||||
// write the final version so the worker processes the correct data.
|
||||
// Skip re-upload when the buffer is reference-identical to the
|
||||
// originally streamed bytes and the filename hasn't changed.
|
||||
const decodedName = filename;
|
||||
const decodedKey = `uploads/${jobId}/${decodedName}`;
|
||||
if (decodedKey !== inputKey) {
|
||||
await putObject(decodedKey, fileBuffer);
|
||||
inputKey = decodedKey;
|
||||
} else if (fileBuffer !== originalBuffer) {
|
||||
await putObject(inputKey, fileBuffer);
|
||||
}
|
||||
// Long tools never block the HTTP request (spec 4.5): straight to SSE.
|
||||
if (shouldSkipSyncWindow(toolMeta?.executionHint)) {
|
||||
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await waitForJob(pool, jobId);
|
||||
if (result) {
|
||||
trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, {
|
||||
tool_id: config.toolId,
|
||||
status: "completed",
|
||||
duration_ms: Date.now() - startTime,
|
||||
category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown",
|
||||
is_ai_tool: getBundleForTool(config.toolId) !== null,
|
||||
});
|
||||
|
||||
// Enqueue for the BullMQ worker
|
||||
await enqueueToolJob({
|
||||
jobId,
|
||||
toolId: config.toolId,
|
||||
userId: getAuthUser(request)?.id ?? null,
|
||||
pool: "image",
|
||||
inputRefs: [inputKey],
|
||||
filename,
|
||||
settings,
|
||||
fileId: fileId ?? undefined,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
kind: "tool",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await waitForJob("image", jobId);
|
||||
if (result) {
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
||||
previewUrl: result.previewRef
|
||||
? `/api/v1/download/${jobId}/${result.previewRef.split("/").pop()}`
|
||||
: undefined,
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
savedFileId: result.savedFileId,
|
||||
});
|
||||
}
|
||||
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
|
||||
} catch (err) {
|
||||
trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, {
|
||||
tool_id: config.toolId,
|
||||
status: "completed",
|
||||
status: "failed",
|
||||
duration_ms: Date.now() - startTime,
|
||||
category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown",
|
||||
is_ai_tool: getBundleForTool(config.toolId) !== null,
|
||||
error_code: err instanceof Error ? err.constructor.name : "UnknownError",
|
||||
error_message:
|
||||
err instanceof Error ? err.message.slice(0, 200) : "Image processing failed",
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
||||
previewUrl: result.previewRef ? `/api/v1/download/${jobId}/preview.webp` : undefined,
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
savedFileId: result.savedFileId,
|
||||
return reply.status(422).send({
|
||||
error: "Processing failed",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
return reply.status(202).send({ jobId: clientJobId || jobId, async: true });
|
||||
} catch (err) {
|
||||
trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, {
|
||||
tool_id: config.toolId,
|
||||
status: "failed",
|
||||
duration_ms: Date.now() - startTime,
|
||||
category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown",
|
||||
is_ai_tool: getBundleForTool(config.toolId) !== null,
|
||||
error_code: err instanceof Error ? err.constructor.name : "UnknownError",
|
||||
error_message:
|
||||
err instanceof Error ? err.message.slice(0, 200) : "Image processing failed",
|
||||
});
|
||||
return reply.status(422).send({
|
||||
error: "Processing failed",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
} finally {
|
||||
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"konva": "^10",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"posthog-js": "^1.377.0",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.2.7",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CATEGORIES, TOOLS } from "@snapotter/shared";
|
||||
import { CATEGORIES, MODALITIES, TOOLS } from "@snapotter/shared";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { getCategoryName } from "@/lib/tool-i18n";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { getCategoryName, getModalityName } from "@/lib/tool-i18n";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { SearchBar } from "../common/search-bar";
|
||||
@@ -38,14 +39,17 @@ export function ToolPanel() {
|
||||
);
|
||||
}, [search, visibleTools]);
|
||||
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups = new Map<string, typeof TOOLS>();
|
||||
const groupedByModality = useMemo(() => {
|
||||
const byModality = new Map<string, Map<string, typeof TOOLS>>();
|
||||
for (const tool of filteredTools) {
|
||||
const list = groups.get(tool.category) || [];
|
||||
const key = tool.modality === "file" ? "document" : tool.modality;
|
||||
const cats = byModality.get(key) ?? new Map<string, typeof TOOLS>();
|
||||
const list = cats.get(tool.category) ?? [];
|
||||
list.push(tool);
|
||||
groups.set(tool.category, list);
|
||||
cats.set(tool.category, list);
|
||||
byModality.set(key, cats);
|
||||
}
|
||||
return groups;
|
||||
return byModality;
|
||||
}, [filteredTools]);
|
||||
|
||||
return (
|
||||
@@ -54,18 +58,41 @@ export function ToolPanel() {
|
||||
<SearchBar value={search} onChange={setSearch} />
|
||||
</div>
|
||||
<div className="px-3 pb-4 flex-1">
|
||||
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
|
||||
<div key={category.id} className="mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
{getCategoryName(t, category.id, category.name)}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{groupedTools.get(category.id)?.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{MODALITIES.filter((m) => m.id !== "file" && groupedByModality.has(m.id)).map(
|
||||
(modality) => {
|
||||
const ModalityIcon = ICON_MAP[modality.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>;
|
||||
const categoryMap = groupedByModality.get(modality.id);
|
||||
if (!categoryMap) return null;
|
||||
return (
|
||||
<div key={modality.id} className="mb-5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{ModalityIcon && <ModalityIcon className="h-4 w-4 text-foreground/70 shrink-0" />}
|
||||
<h2 className="text-xs font-bold uppercase text-foreground/70 tracking-wider">
|
||||
{getModalityName(
|
||||
t,
|
||||
modality.id,
|
||||
modality.id === "document" ? "Documents & Files" : modality.name,
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{CATEGORIES.filter((cat) => categoryMap.has(cat.id)).map((category) => (
|
||||
<div key={category.id} className="mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
{getCategoryName(t, category.id, category.name)}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{categoryMap.get(category.id)?.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{filteredTools.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/** pdf.js canvas viewer for the document display mode (spec 4.6). */
|
||||
export function DocumentView() {
|
||||
const { t } = useTranslation();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const src = entry?.processedUrl ?? entry?.blobUrl;
|
||||
|
||||
/* A+B: reset pagination and clear stale errors when the document changes.
|
||||
src is intentionally a trigger-only dep (not read inside the callback). */
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: src is the trigger
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setPageCount(0);
|
||||
setError(null);
|
||||
}, [src]);
|
||||
|
||||
/* C+D: cancel in-flight renders and destroy the doc proxy on cleanup. */
|
||||
useEffect(() => {
|
||||
if (!src || !canvasRef.current) return;
|
||||
let cancelled = false;
|
||||
let doc: pdfjs.PDFDocumentProxy | undefined;
|
||||
let renderTask: pdfjs.RenderTask | undefined;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
doc = await pdfjs.getDocument({ url: src }).promise;
|
||||
if (cancelled) return;
|
||||
setPageCount(doc.numPages);
|
||||
const pdfPage = await doc.getPage(Math.min(page, doc.numPages));
|
||||
if (cancelled) return;
|
||||
const viewport = pdfPage.getViewport({ scale: 1.2 });
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
renderTask = pdfPage.render({ canvas, viewport });
|
||||
await renderTask.promise;
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderTask?.cancel();
|
||||
doc?.loadingTask.destroy();
|
||||
};
|
||||
}, [src, page]);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center gap-2 overflow-auto p-4">
|
||||
{error && <p className="p-4 text-sm text-destructive">{t.tools.documentView.loadFailed}</p>}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`max-w-full rounded border${error ? " hidden" : ""}`}
|
||||
data-testid="document-canvas"
|
||||
/>
|
||||
{!error && pageCount > 1 && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="disabled:opacity-50"
|
||||
>
|
||||
{t.tools.documentView.previousPage}
|
||||
</button>
|
||||
<span>
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="disabled:opacity-50"
|
||||
>
|
||||
{t.tools.documentView.nextPage}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
/**
|
||||
* Native <video>/<audio> playback over the Range-capable download endpoint
|
||||
* (spec 4.6). Shows the processed result when present, else the source file.
|
||||
*/
|
||||
export function MediaPlayerView() {
|
||||
const { t } = useTranslation();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
if (!entry) return null;
|
||||
const src = entry.processedUrl ?? entry.blobUrl;
|
||||
const isAudio = entry.modality === "audio";
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
{isAudio ? (
|
||||
<audio controls src={src} className="w-full max-w-xl" data-testid="media-player-audio">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
) : (
|
||||
<video
|
||||
controls
|
||||
src={src}
|
||||
className="max-h-full max-w-full rounded-lg"
|
||||
data-testid="media-player-video"
|
||||
>
|
||||
<track kind="captions" />
|
||||
{t.tools.mediaPlayer.unsupported}
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Expand,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileArchive,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
FilePen,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
Type,
|
||||
Undo2,
|
||||
UserCheck,
|
||||
Video,
|
||||
Wand,
|
||||
Wrench,
|
||||
Zap,
|
||||
@@ -76,6 +78,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Expand,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileArchive,
|
||||
FilePen,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
@@ -115,6 +118,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Type,
|
||||
Undo2,
|
||||
UserCheck,
|
||||
Video,
|
||||
Wand,
|
||||
Wrench,
|
||||
Zap,
|
||||
|
||||
@@ -15,7 +15,9 @@ export type DisplayMode =
|
||||
| "interactive-eraser"
|
||||
| "interactive-split"
|
||||
| "no-dropzone"
|
||||
| "custom-results";
|
||||
| "custom-results"
|
||||
| "media-player"
|
||||
| "document";
|
||||
|
||||
export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
// Essentials
|
||||
|
||||
@@ -13,3 +13,13 @@ export function getToolDescription(t: TranslationKeys, toolId: string, fallback:
|
||||
export function getCategoryName(t: TranslationKeys, categoryId: string, fallback: string): string {
|
||||
return (t.categories as Record<string, string>)[categoryId] ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the i18n display name for a modality group header.
|
||||
* The "document" key maps to the merged "documentsAndFiles" i18n entry;
|
||||
* "file" is never rendered as a top-level header (merged into document).
|
||||
*/
|
||||
export function getModalityName(t: TranslationKeys, modalityId: string, fallback: string): string {
|
||||
const key = modalityId === "document" ? "documentsAndFiles" : modalityId;
|
||||
return (t.modalities as Record<string, string>)[key] ?? fallback;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
|
||||
import {
|
||||
CATEGORIES,
|
||||
MODALITIES,
|
||||
PYTHON_SIDECAR_TOOLS,
|
||||
TOOL_BUNDLE_MAP,
|
||||
TOOLS,
|
||||
} from "@snapotter/shared";
|
||||
import { Clock, Download, Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -8,7 +14,7 @@ import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { getCategoryName, getToolName } from "@/lib/tool-i18n";
|
||||
import { getCategoryName, getModalityName, getToolName } from "@/lib/tool-i18n";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -281,68 +287,103 @@ export function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All tools by category */}
|
||||
{/* All tools by modality and category */}
|
||||
<div className="p-4">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||
{t.homePage.allTools}
|
||||
</h3>
|
||||
{CATEGORIES.map((category) => {
|
||||
const categoryTools = TOOLS.filter((t) => t.category === category.id);
|
||||
if (categoryTools.length === 0) return null;
|
||||
{MODALITIES.filter((m) => {
|
||||
if (m.id === "file") return false;
|
||||
const key = m.id;
|
||||
return TOOLS.some(
|
||||
(tool) => tool.modality === key || (key === "document" && tool.modality === "file"),
|
||||
);
|
||||
}).map((modality) => {
|
||||
const ModalityIcon = ICON_MAP[modality.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>;
|
||||
const modalityTools = TOOLS.filter(
|
||||
(tool) =>
|
||||
tool.modality === modality.id ||
|
||||
(modality.id === "document" && tool.modality === "file"),
|
||||
);
|
||||
const categoryMap = new Map<string, typeof TOOLS>();
|
||||
for (const tool of modalityTools) {
|
||||
const list = categoryMap.get(tool.category) ?? [];
|
||||
list.push(tool);
|
||||
categoryMap.set(tool.category, list);
|
||||
}
|
||||
return (
|
||||
<div key={category.id} className="mb-4">
|
||||
<p
|
||||
className="text-xs font-medium text-muted-foreground mb-1.5"
|
||||
style={{ color: category.color }}
|
||||
>
|
||||
{getCategoryName(t, category.id, category.name)}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{categoryTools.map((tool) => {
|
||||
const Icon =
|
||||
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ??
|
||||
ICON_MAP.FileImage;
|
||||
const status = getToolStatus(tool.id);
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => navigate(tool.route)}
|
||||
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-start transition-colors hover:bg-muted text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
|
||||
{status === "not_installed" && (
|
||||
<>
|
||||
<Download
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.notInstalled}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<>
|
||||
<Clock
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.queued}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "installing" && (
|
||||
<>
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.installing}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div key={modality.id} className="mb-5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{ModalityIcon && (
|
||||
<ModalityIcon className="h-4 w-4 text-foreground/70 shrink-0" />
|
||||
)}
|
||||
<p className="text-xs font-bold uppercase text-foreground/70 tracking-wider">
|
||||
{getModalityName(
|
||||
t,
|
||||
modality.id,
|
||||
modality.id === "document" ? "Documents & Files" : modality.name,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{CATEGORIES.filter((cat) => categoryMap.has(cat.id)).map((category) => (
|
||||
<div key={category.id} className="mb-4">
|
||||
<p
|
||||
className="text-xs font-medium text-muted-foreground mb-1.5"
|
||||
style={{ color: category.color }}
|
||||
>
|
||||
{getCategoryName(t, category.id, category.name)}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{categoryMap.get(category.id)?.map((tool) => {
|
||||
const Icon =
|
||||
(ICON_MAP[tool.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>) ?? ICON_MAP.FileImage;
|
||||
const status = getToolStatus(tool.id);
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => navigate(tool.route)}
|
||||
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-start transition-colors hover:bg-muted text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
|
||||
{status === "not_installed" && (
|
||||
<>
|
||||
<Download
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.notInstalled}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<>
|
||||
<Clock
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.queued}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "installing" && (
|
||||
<>
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.installing}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FileImage,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Crop } from "react-image-crop";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
@@ -41,6 +41,13 @@ import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
|
||||
import { useQrStore } from "@/stores/qr-store";
|
||||
import { useSplitStore } from "@/stores/split-store";
|
||||
|
||||
const MediaPlayerView = lazy(() =>
|
||||
import("@/components/tools/media-player-view").then((m) => ({ default: m.MediaPlayerView })),
|
||||
);
|
||||
const DocumentView = lazy(() =>
|
||||
import("@/components/tools/document-view").then((m) => ({ default: m.DocumentView })),
|
||||
);
|
||||
|
||||
/** Formats that browsers can render in <img> tags. */
|
||||
const BROWSER_PREVIEWABLE_EXTS = new Set([
|
||||
"jpg",
|
||||
@@ -469,6 +476,24 @@ export function ToolPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Media player: native <video>/<audio> element
|
||||
if (displayMode === "media-player" && hasFile) {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<MediaPlayerView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Document viewer: pdf.js canvas with pagination
|
||||
if (displayMode === "document" && hasFile) {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<DocumentView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state for failed batch files (before interactive canvas blocks,
|
||||
// which also match !hasProcessed and would show the canvas instead of the error)
|
||||
if (hasFile && !hasProcessed && currentEntry?.status === "failed") {
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { detectModalityFromMime, type Modality } from "@snapotter/shared";
|
||||
import { create } from "zustand";
|
||||
import { fetchDecodedPreview, needsServerPreview } from "@/lib/image-preview";
|
||||
|
||||
export type PreviewKind = "image" | "video" | "audio" | "document" | "none";
|
||||
|
||||
export function previewKindFor(modality: Modality): PreviewKind {
|
||||
switch (modality) {
|
||||
case "image":
|
||||
return "image";
|
||||
case "video":
|
||||
return "video";
|
||||
case "audio":
|
||||
return "audio";
|
||||
case "document":
|
||||
return "document";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
file: File;
|
||||
blobUrl: string;
|
||||
@@ -15,6 +33,8 @@ export interface FileEntry {
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
error: string | null;
|
||||
serverFileId?: string;
|
||||
modality: Modality;
|
||||
previewKind: PreviewKind;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,6 +42,7 @@ export interface FileEntry {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createEntry(file: File): FileEntry {
|
||||
const modality = detectModalityFromMime(file.type);
|
||||
return {
|
||||
file,
|
||||
blobUrl: URL.createObjectURL(file),
|
||||
@@ -36,6 +57,8 @@ function createEntry(file: File): FileEntry {
|
||||
status: "pending",
|
||||
error: null,
|
||||
serverFileId: undefined,
|
||||
modality,
|
||||
previewKind: previewKindFor(modality),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+23
-1
@@ -4,6 +4,12 @@
|
||||
# Single image: GPU auto-detected on amd64, CPU on arm64
|
||||
# ============================================
|
||||
|
||||
# ============================================
|
||||
# Stage 0: Static FFmpeg/FFprobe binaries
|
||||
# ============================================
|
||||
# Multi-arch (amd64 + arm64) static builds for video/audio processing.
|
||||
FROM mwader/static-ffmpeg:8.0 AS ffmpeg
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Build the frontend (Vite + React)
|
||||
# ============================================
|
||||
@@ -25,6 +31,8 @@ COPY apps/web/postcss.config.js ./apps/web/
|
||||
COPY apps/api/package.json apps/api/tsconfig.json ./apps/api/
|
||||
COPY packages/shared/package.json packages/shared/tsconfig.json ./packages/shared/
|
||||
COPY packages/image-engine/package.json packages/image-engine/tsconfig.json ./packages/image-engine/
|
||||
COPY packages/media-engine/package.json packages/media-engine/tsconfig.json ./packages/media-engine/
|
||||
COPY packages/doc-engine/package.json packages/doc-engine/tsconfig.json ./packages/doc-engine/
|
||||
COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
|
||||
|
||||
# Install ALL dependencies (dev + prod needed for building)
|
||||
@@ -193,6 +201,10 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $(
|
||||
python3 python3-pip python3-venv python3-dev \
|
||||
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \
|
||||
tesseract-ocr-chi-sim tesseract-ocr-jpn tesseract-ocr-kor \
|
||||
# Document engine: qpdf + LibreOffice headless
|
||||
# WeasyPrint/calibre/pdfcpu arrive with the tools that need them (Phase 4+)
|
||||
qpdf \
|
||||
libreoffice-writer libreoffice-calc \
|
||||
gcc g++ \
|
||||
libgl1 libglib2.0-0 libgles2 \
|
||||
libegl1 libwayland-egl1 libwayland-client0 libwayland-cursor0 \
|
||||
@@ -225,6 +237,10 @@ RUN POLICY_FILE=$(find /etc/ImageMagick* -name policy.xml 2>/dev/null | head -1)
|
||||
# Caire binary (content-aware seam carving)
|
||||
COPY --from=caire-builder /tmp/caire /usr/local/bin/caire
|
||||
|
||||
# FFmpeg + FFprobe static binaries (video/audio engine)
|
||||
COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
|
||||
|
||||
# libheif tools (heif-convert, heif-dec, heif-enc) built from source.
|
||||
# LD_LIBRARY_PATH ensures our custom 1.21.2 libs take precedence over distro libheif1.
|
||||
COPY --from=libheif-builder /opt/libheif/bin/ /usr/local/bin/
|
||||
@@ -241,7 +257,9 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install \
|
||||
Pillow==12.2.0 \
|
||||
numpy==1.26.4 \
|
||||
opencv-python-headless==4.10.0.84
|
||||
opencv-python-headless==4.10.0.84 \
|
||||
pikepdf==10.8.0 \
|
||||
PyMuPDF==1.27.2.3
|
||||
|
||||
# On-demand AI feature installer and manifest
|
||||
COPY docker/feature-manifest.json /app/docker/feature-manifest.json
|
||||
@@ -256,6 +274,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json turbo.json tsconfig.base.js
|
||||
COPY apps/api/package.json apps/api/tsconfig.json ./apps/api/
|
||||
COPY packages/shared/package.json packages/shared/tsconfig.json ./packages/shared/
|
||||
COPY packages/image-engine/package.json packages/image-engine/tsconfig.json ./packages/image-engine/
|
||||
COPY packages/media-engine/package.json packages/media-engine/tsconfig.json ./packages/media-engine/
|
||||
COPY packages/doc-engine/package.json packages/doc-engine/tsconfig.json ./packages/doc-engine/
|
||||
COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
|
||||
|
||||
# Install production dependencies (tsx is now in prod deps)
|
||||
@@ -276,6 +296,8 @@ COPY apps/api/static ./apps/api/static
|
||||
# Copy workspace packages source (referenced by API at runtime)
|
||||
COPY packages/shared/src ./packages/shared/src
|
||||
COPY packages/image-engine/src ./packages/image-engine/src
|
||||
COPY packages/media-engine/src ./packages/media-engine/src
|
||||
COPY packages/doc-engine/src ./packages/doc-engine/src
|
||||
COPY packages/ai/src ./packages/ai/src
|
||||
COPY packages/ai/python ./packages/ai/python
|
||||
|
||||
|
||||
@@ -52,6 +52,22 @@ ALLOWED_SCRIPTS = {
|
||||
# No path separators, no dots, no spaces, no special characters.
|
||||
_SCRIPT_NAME_RE = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
# ── Dispatcher profiles ───────────────────────────────────────────────
|
||||
# The "ai" profile (default) uses ALLOWED_SCRIPTS for the full AI tool set
|
||||
# and pre-imports heavy ML libraries. The "docs" profile replaces the
|
||||
# allowlist with a lean set of document-processing scripts and skips all
|
||||
# heavy AI imports so the instance starts fast.
|
||||
|
||||
DISPATCHER_PROFILE = os.environ.get("DISPATCHER_PROFILE", "ai")
|
||||
|
||||
DOCS_SCRIPTS = {
|
||||
"doc_pagecount",
|
||||
"doc_health",
|
||||
}
|
||||
|
||||
if DISPATCHER_PROFILE == "docs":
|
||||
ALLOWED_SCRIPTS = DOCS_SCRIPTS
|
||||
|
||||
|
||||
INSTALLED_PATH = os.path.join(os.environ.get("DATA_DIR", "/data"), "ai", "installed.json")
|
||||
MODELS_DIR = os.path.join(os.environ.get("DATA_DIR", "/data"), "ai", "models")
|
||||
@@ -85,39 +101,10 @@ def emit_progress(percent, stage):
|
||||
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# ── basicsr / torchvision compatibility shim ──────────────────────────
|
||||
# basicsr 1.4.2 (pulled in by realesrgan) does:
|
||||
# from torchvision.transforms.functional_tensor import rgb_to_grayscale
|
||||
# but torchvision >= 0.17 removed the functional_tensor submodule,
|
||||
# merging everything into torchvision.transforms.functional.
|
||||
# We install a shim module ONCE here so every script in this process
|
||||
# benefits, rather than relying on each script to patch individually.
|
||||
try:
|
||||
import torchvision.transforms.functional_tensor # noqa: F401
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
try:
|
||||
import types
|
||||
import torchvision.transforms.functional as _F
|
||||
import torchvision.transforms
|
||||
|
||||
_shim = types.ModuleType("torchvision.transforms.functional_tensor")
|
||||
_shim.__getattr__ = lambda name: getattr(_F, name)
|
||||
_shim.rgb_to_grayscale = _F.rgb_to_grayscale
|
||||
sys.modules["torchvision.transforms.functional_tensor"] = _shim
|
||||
torchvision.transforms.functional_tensor = _shim
|
||||
print("[dispatcher] Installed torchvision.transforms.functional_tensor shim",
|
||||
file=sys.stderr, flush=True)
|
||||
except (ImportError, AttributeError):
|
||||
# torchvision not installed yet — shim not needed until
|
||||
# the upscale-enhance bundle is installed.
|
||||
pass
|
||||
except Exception:
|
||||
# Catch-all so dispatcher startup is never blocked.
|
||||
pass
|
||||
|
||||
# ── Pre-import heavy libraries ──────────────────────────────────────
|
||||
# These imports are the main source of cold-start latency.
|
||||
# By importing once at startup, subsequent requests skip the import cost.
|
||||
# The docs profile skips all of these so it starts lean and fast.
|
||||
|
||||
available_modules = {}
|
||||
|
||||
@@ -129,17 +116,48 @@ def _try_import(name, import_fn):
|
||||
print(f"[dispatcher] Module '{name}' not available: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
_try_import("PIL", lambda: __import__("PIL"))
|
||||
_try_import("mediapipe", lambda: __import__("mediapipe"))
|
||||
_try_import("numpy", lambda: __import__("numpy"))
|
||||
_try_import("gpu", lambda: __import__("gpu"))
|
||||
if DISPATCHER_PROFILE == "ai":
|
||||
# ── basicsr / torchvision compatibility shim ──────────────────
|
||||
# basicsr 1.4.2 (pulled in by realesrgan) does:
|
||||
# from torchvision.transforms.functional_tensor import rgb_to_grayscale
|
||||
# but torchvision >= 0.17 removed the functional_tensor submodule,
|
||||
# merging everything into torchvision.transforms.functional.
|
||||
# We install a shim module ONCE here so every script in this process
|
||||
# benefits, rather than relying on each script to patch individually.
|
||||
try:
|
||||
import torchvision.transforms.functional_tensor # noqa: F401
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
try:
|
||||
import types
|
||||
import torchvision.transforms.functional as _F
|
||||
import torchvision.transforms
|
||||
|
||||
# Heavy ML libraries - import but don't fail if unavailable
|
||||
_try_import("rembg", lambda: __import__("rembg"))
|
||||
_shim = types.ModuleType("torchvision.transforms.functional_tensor")
|
||||
_shim.__getattr__ = lambda name: getattr(_F, name)
|
||||
_shim.rgb_to_grayscale = _F.rgb_to_grayscale
|
||||
sys.modules["torchvision.transforms.functional_tensor"] = _shim
|
||||
torchvision.transforms.functional_tensor = _shim
|
||||
print("[dispatcher] Installed torchvision.transforms.functional_tensor shim",
|
||||
file=sys.stderr, flush=True)
|
||||
except (ImportError, AttributeError):
|
||||
# torchvision not installed yet -- shim not needed until
|
||||
# the upscale-enhance bundle is installed.
|
||||
pass
|
||||
except Exception:
|
||||
# Catch-all so dispatcher startup is never blocked.
|
||||
pass
|
||||
|
||||
# Point rembg at the bundled model directory if it exists
|
||||
if os.path.isdir(MODELS_DIR):
|
||||
os.environ.setdefault("U2NET_HOME", os.path.join(MODELS_DIR, "rembg"))
|
||||
_try_import("PIL", lambda: __import__("PIL"))
|
||||
_try_import("mediapipe", lambda: __import__("mediapipe"))
|
||||
_try_import("numpy", lambda: __import__("numpy"))
|
||||
_try_import("gpu", lambda: __import__("gpu"))
|
||||
|
||||
# Heavy ML libraries - import but don't fail if unavailable
|
||||
_try_import("rembg", lambda: __import__("rembg"))
|
||||
|
||||
# Point rembg at the bundled model directory if it exists
|
||||
if os.path.isdir(MODELS_DIR):
|
||||
os.environ.setdefault("U2NET_HOME", os.path.join(MODELS_DIR, "rembg"))
|
||||
|
||||
|
||||
# ── Script handlers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Health probe for the docs-profile dispatcher. No dependencies. Prints {"ok": true}."""
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
print(json.dumps({"ok": True}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Page count via pikepdf. Args: {"path": "/abs/file.pdf"}. Prints {"pages": N}."""
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
||||
path = args.get("path")
|
||||
if not path:
|
||||
print(json.dumps({"error": "missing path"}))
|
||||
sys.exit(1)
|
||||
try:
|
||||
import pikepdf
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "pikepdf not installed"}))
|
||||
sys.exit(1)
|
||||
try:
|
||||
with pikepdf.open(path) as pdf:
|
||||
print(json.dumps({"pages": len(pdf.pages)}))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"error": str(exc)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+537
-438
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,13 @@ export { removeBackground } from "./background-removal.js";
|
||||
export type { DispatcherStatus } from "./bridge.js";
|
||||
export {
|
||||
getDispatcherStatus,
|
||||
getDocsDispatcher,
|
||||
initDispatcher,
|
||||
isGpuAvailable,
|
||||
PythonDispatcher,
|
||||
runDocsScript,
|
||||
shutdownDispatcher,
|
||||
shutdownDocsDispatcher,
|
||||
} from "./bridge.js";
|
||||
export { colorize } from "./colorization.js";
|
||||
export type { DetectFacesResult, FaceRegion } from "./face-detection.js";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@snapotter/doc-engine",
|
||||
"version": "1.17.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "biome check src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snapotter/ai": "workspace:*",
|
||||
"@snapotter/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const cache = new Map<string, string | null>();
|
||||
|
||||
function which(bin: string): string | null {
|
||||
const res = spawnSync(process.platform === "win32" ? "where" : "which", [bin], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (res.status === 0 && res.stdout.trim()) return res.stdout.trim().split("\n")[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveBin(envVar: string, name: string): string | null {
|
||||
const key = `${envVar}:${name}`;
|
||||
if (!cache.has(key)) cache.set(key, process.env[envVar] || which(name));
|
||||
return cache.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function resolveQpdf(): string | null {
|
||||
return resolveBin("QPDF_PATH", "qpdf");
|
||||
}
|
||||
export function resolveSoffice(): string | null {
|
||||
return resolveBin("SOFFICE_PATH", "soffice");
|
||||
}
|
||||
export function resolveGs(): string | null {
|
||||
return resolveBin("GS_PATH", "gs");
|
||||
}
|
||||
export function qpdfAvailable(): boolean {
|
||||
return resolveQpdf() !== null;
|
||||
}
|
||||
export function sofficeAvailable(): boolean {
|
||||
return resolveSoffice() !== null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
qpdfAvailable,
|
||||
resolveGs,
|
||||
resolveQpdf,
|
||||
resolveSoffice,
|
||||
sofficeAvailable,
|
||||
} from "./binaries.js";
|
||||
export { type ConvertOptions, convertDocument } from "./libreoffice.js";
|
||||
export { pdfPageCountPy } from "./python-docs.js";
|
||||
export { qpdfCheck, qpdfPageCount } from "./qpdf.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveSoffice } from "./binaries.js";
|
||||
|
||||
export interface ConvertOptions {
|
||||
timeoutMs?: number; // default 120s, spec 4.7 hard kill
|
||||
}
|
||||
|
||||
/**
|
||||
* LibreOffice headless conversion with per-invocation profile isolation
|
||||
* (spec 4.7): each run gets its own UserInstallation dir so concurrent
|
||||
* conversions cannot corrupt a shared profile; the profile is removed in
|
||||
* finally and the process is SIGKILLed at the deadline.
|
||||
* Returns the produced file path inside outDir.
|
||||
*/
|
||||
export async function convertDocument(
|
||||
inputPath: string,
|
||||
outDir: string,
|
||||
targetExt: string,
|
||||
opts: ConvertOptions = {},
|
||||
): Promise<string> {
|
||||
const bin = resolveSoffice();
|
||||
if (!bin) throw new Error("soffice binary not found (set SOFFICE_PATH or install LibreOffice)");
|
||||
const timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
const profileDir = join(tmpdir(), `snapotter-lo-${randomUUID()}`);
|
||||
try {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
bin,
|
||||
[
|
||||
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
||||
"--headless",
|
||||
"--norestore",
|
||||
"--nolockcheck",
|
||||
"--nodefault",
|
||||
"--convert-to",
|
||||
targetExt,
|
||||
"--outdir",
|
||||
outDir,
|
||||
inputPath,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let err = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`LibreOffice timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
child.stderr.on("data", (c: Buffer) => {
|
||||
err = (err + c.toString("utf8")).slice(-4096);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`LibreOffice exited ${code ?? signal}: ${err.slice(-1000)}`));
|
||||
});
|
||||
});
|
||||
const expected = `${basename(inputPath, extname(inputPath))}.${targetExt}`;
|
||||
const produced = (await readdir(outDir)).find((f) => f === expected);
|
||||
if (!produced)
|
||||
throw new Error(`LibreOffice produced no ${targetExt} output for ${basename(inputPath)}`);
|
||||
return join(outDir, produced);
|
||||
} finally {
|
||||
await rm(profileDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { runDocsScript } from "@snapotter/ai";
|
||||
|
||||
/** Page count via the docs-profile Python dispatcher (pikepdf). */
|
||||
export async function pdfPageCountPy(absPath: string): Promise<number> {
|
||||
const stdout = await runDocsScript("doc_pagecount", { path: absPath });
|
||||
const parsed = JSON.parse(stdout.trim()) as { pages?: number; error?: string };
|
||||
if (parsed.error || typeof parsed.pages !== "number") {
|
||||
throw new Error(`doc_pagecount failed: ${parsed.error ?? stdout.slice(0, 200)}`);
|
||||
}
|
||||
return parsed.pages;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveQpdf } from "./binaries.js";
|
||||
|
||||
function runQpdf(args: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const bin = resolveQpdf();
|
||||
if (!bin) throw new Error("qpdf binary not found (set QPDF_PATH or install qpdf)");
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`qpdf timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
child.stdout.on("data", (c: Buffer) => {
|
||||
out += c.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (c: Buffer) => {
|
||||
err = (err + c.toString("utf8")).slice(-4096);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolvePromise(out);
|
||||
else
|
||||
reject(new Error(`qpdf exited ${code ?? signal}: ${err.slice(-1000) || out.slice(-1000)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Structural check; throws with qpdf's diagnostics on damage. */
|
||||
export async function qpdfCheck(filePath: string): Promise<void> {
|
||||
await runQpdf(["--check", filePath]);
|
||||
}
|
||||
|
||||
export async function qpdfPageCount(filePath: string): Promise<number> {
|
||||
const out = await runQpdf(["--show-npages", filePath]);
|
||||
const n = Number(out.trim());
|
||||
if (!Number.isFinite(n)) throw new Error(`qpdf returned a non-numeric page count: ${out.trim()}`);
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@snapotter/media-engine",
|
||||
"version": "1.17.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "biome check src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snapotter/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
let ffmpegPath: string | null | undefined;
|
||||
let ffprobePath: string | null | undefined;
|
||||
|
||||
function which(bin: string): string | null {
|
||||
const res = spawnSync(process.platform === "win32" ? "where" : "which", [bin], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (res.status === 0 && res.stdout.trim()) return res.stdout.trim().split("\n")[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** FFMPEG_PATH env override, else $PATH. Null when unavailable. Cached. */
|
||||
export function resolveFfmpeg(): string | null {
|
||||
if (ffmpegPath === undefined) ffmpegPath = process.env.FFMPEG_PATH || which("ffmpeg");
|
||||
return ffmpegPath;
|
||||
}
|
||||
|
||||
export function resolveFfprobe(): string | null {
|
||||
if (ffprobePath === undefined) ffprobePath = process.env.FFPROBE_PATH || which("ffprobe");
|
||||
return ffprobePath;
|
||||
}
|
||||
|
||||
export function ffmpegAvailable(): boolean {
|
||||
return resolveFfmpeg() !== null && resolveFfprobe() !== null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type EncoderTarget = "h264" | "hevc" | "av1" | "vp9" | "aac" | "opus" | "mp3";
|
||||
|
||||
const SOFTWARE: Record<EncoderTarget, string> = {
|
||||
h264: "libx264",
|
||||
hevc: "libx265",
|
||||
av1: "libsvtav1",
|
||||
vp9: "libvpx-vp9",
|
||||
aac: "aac",
|
||||
opus: "libopus",
|
||||
mp3: "libmp3lame",
|
||||
};
|
||||
|
||||
const NVENC: Partial<Record<EncoderTarget, string>> = {
|
||||
h264: "h264_nvenc",
|
||||
hevc: "hevc_nvenc",
|
||||
av1: "av1_nvenc",
|
||||
};
|
||||
|
||||
const VAAPI: Partial<Record<EncoderTarget, string>> = {
|
||||
h264: "h264_vaapi",
|
||||
hevc: "hevc_vaapi",
|
||||
};
|
||||
|
||||
/**
|
||||
* Hardware-acceleration seam (spec 4.5): SNAPOTTER_HW_ACCEL selects an
|
||||
* encoder family; a CUDA/NVENC deployment is a Dockerfile change, not a
|
||||
* code change. Unknown values fall back to software.
|
||||
*/
|
||||
export function resolveEncoder(target: EncoderTarget): string {
|
||||
const accel = (process.env.SNAPOTTER_HW_ACCEL ?? "").toLowerCase();
|
||||
if (accel === "nvenc") return NVENC[target] ?? SOFTWARE[target];
|
||||
if (accel === "vaapi") return VAAPI[target] ?? SOFTWARE[target];
|
||||
return SOFTWARE[target];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveFfmpeg } from "./binaries.js";
|
||||
import { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
||||
|
||||
export interface RunFfmpegOptions {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
onProgress?: (p: FfmpegProgress) => void;
|
||||
}
|
||||
|
||||
const STDERR_RING_MAX = 16 * 1024;
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `-progress pipe:1` appended, parsing progress blocks from
|
||||
* stdout. Rejects with the tail of stderr on non-zero exit, timeout or abort.
|
||||
* Output must go to a FILE path in args (no stdout piping of media data).
|
||||
*/
|
||||
export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise<void> {
|
||||
const bin = resolveFfmpeg();
|
||||
if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)");
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(bin, ["-hide_banner", "-nostdin", "-y", ...args, "-progress", "pipe:1"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderrTail = "";
|
||||
let settled = false;
|
||||
let buffer = "";
|
||||
const timeoutMs = opts.timeoutMs;
|
||||
const timer = timeoutMs
|
||||
? setTimeout(() => {
|
||||
fail(new Error(`ffmpeg timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
const onAbort = () => fail(new Error("Canceled"));
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) onAbort();
|
||||
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
function fail(err: Error) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.kill("SIGKILL");
|
||||
reject(err);
|
||||
}
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
// Blocks end at the line that starts with "progress="
|
||||
let idx = buffer.indexOf("progress=");
|
||||
while (idx !== -1) {
|
||||
const lineEnd = buffer.indexOf("\n", idx);
|
||||
if (lineEnd === -1) break;
|
||||
const block = buffer.slice(0, lineEnd);
|
||||
buffer = buffer.slice(lineEnd + 1);
|
||||
try {
|
||||
opts.onProgress?.(parseProgressBlock(block));
|
||||
} catch (cbErr) {
|
||||
fail(cbErr instanceof Error ? cbErr : new Error(String(cbErr)));
|
||||
return;
|
||||
}
|
||||
idx = buffer.indexOf("progress=");
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString("utf8")).slice(-STDERR_RING_MAX);
|
||||
});
|
||||
child.on("error", (err) => fail(err));
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveFfprobe } from "./binaries.js";
|
||||
|
||||
export interface MediaStreamInfo {
|
||||
type: "video" | "audio" | "other";
|
||||
codec: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface MediaInfo {
|
||||
container: string;
|
||||
durationS: number | null;
|
||||
bitrateKbps: number | null;
|
||||
streams: MediaStreamInfo[];
|
||||
}
|
||||
|
||||
export interface ProbeOptions {
|
||||
timeoutMs?: number; // default 15s
|
||||
}
|
||||
|
||||
/** Capped, time-limited ffprobe of a file path (spec 4.7). */
|
||||
export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Promise<MediaInfo> {
|
||||
const bin = resolveFfprobe();
|
||||
if (!bin) throw new Error("ffprobe binary not found (set FFPROBE_PATH or install ffmpeg)");
|
||||
const args = [
|
||||
"-v",
|
||||
"error",
|
||||
"-analyzeduration",
|
||||
"10M",
|
||||
"-probesize",
|
||||
"25M",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
];
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000;
|
||||
const stdout = await new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`ffprobe timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
child.stdout.on("data", (c: Buffer) => {
|
||||
out += c.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (c: Buffer) => {
|
||||
err = (err + c.toString("utf8")).slice(-4096);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolvePromise(out);
|
||||
else reject(new Error(`ffprobe exited ${code ?? signal}: ${err.slice(-1000)}`));
|
||||
});
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
format?: { format_name?: string; duration?: string; bit_rate?: string };
|
||||
streams?: Array<{ codec_type?: string; codec_name?: string; width?: number; height?: number }>;
|
||||
};
|
||||
const duration = parsed.format?.duration ? Number(parsed.format.duration) : null;
|
||||
const bitRate = parsed.format?.bit_rate ? Number(parsed.format.bit_rate) : null;
|
||||
return {
|
||||
container: parsed.format?.format_name ?? "unknown",
|
||||
durationS: Number.isFinite(duration as number) ? (duration as number) : null,
|
||||
bitrateKbps: Number.isFinite(bitRate as number) ? Math.round((bitRate as number) / 1000) : null,
|
||||
streams: (parsed.streams ?? []).map((s) => ({
|
||||
type: s.codec_type === "video" ? "video" : s.codec_type === "audio" ? "audio" : "other",
|
||||
codec: s.codec_name ?? "unknown",
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ffmpegAvailable, resolveFfmpeg, resolveFfprobe } from "./binaries.js";
|
||||
export { type EncoderTarget, resolveEncoder } from "./encoders.js";
|
||||
export { type RunFfmpegOptions, runFfmpeg } from "./ffmpeg.js";
|
||||
export { type MediaInfo, type MediaStreamInfo, type ProbeOptions, probeMedia } from "./ffprobe.js";
|
||||
export { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface FfmpegProgress {
|
||||
outTimeMs: number | null;
|
||||
done: boolean;
|
||||
raw: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Parses one `-progress pipe:1` block (key=value lines ending in progress=...). */
|
||||
export function parseProgressBlock(block: string): FfmpegProgress {
|
||||
const raw: Record<string, string> = {};
|
||||
for (const line of block.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx > 0) raw[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
let outTimeMs: number | null = null;
|
||||
if (raw.out_time_us !== undefined) {
|
||||
const us = Number(raw.out_time_us);
|
||||
if (Number.isFinite(us)) outTimeMs = Math.round(us / 1000);
|
||||
} else if (raw.out_time_ms !== undefined) {
|
||||
// ffmpeg's out_time_ms is historically MICROseconds despite the name.
|
||||
const us = Number(raw.out_time_ms);
|
||||
if (Number.isFinite(us)) outTimeMs = Math.round(us / 1000);
|
||||
}
|
||||
return { outTimeMs, done: raw.progress === "end", raw };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IMAGE_INPUTS } from "./modality.js";
|
||||
import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js";
|
||||
|
||||
export const CATEGORIES: CategoryInfo[] = [
|
||||
@@ -20,6 +21,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "essentials",
|
||||
icon: "Maximize2",
|
||||
route: "/resize",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "crop",
|
||||
@@ -28,6 +32,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "essentials",
|
||||
icon: "Crop",
|
||||
route: "/crop",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "rotate",
|
||||
@@ -36,6 +43,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "essentials",
|
||||
icon: "RotateCw",
|
||||
route: "/rotate",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "convert",
|
||||
@@ -44,6 +54,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "essentials",
|
||||
icon: "FileOutput",
|
||||
route: "/convert",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "compress",
|
||||
@@ -52,6 +65,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "essentials",
|
||||
icon: "Minimize2",
|
||||
route: "/compress",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Optimization
|
||||
{
|
||||
@@ -62,6 +78,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "optimization",
|
||||
icon: "Globe",
|
||||
route: "/optimize-for-web",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "strip-metadata",
|
||||
@@ -70,6 +89,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "optimization",
|
||||
icon: "ShieldOff",
|
||||
route: "/strip-metadata",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "edit-metadata",
|
||||
@@ -78,6 +100,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "optimization",
|
||||
icon: "PenLine",
|
||||
route: "/edit-metadata",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "bulk-rename",
|
||||
@@ -86,6 +111,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "optimization",
|
||||
icon: "FilePen",
|
||||
route: "/bulk-rename",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "image-to-pdf",
|
||||
@@ -94,6 +122,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "optimization",
|
||||
icon: "FileText",
|
||||
route: "/image-to-pdf",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "favicon",
|
||||
@@ -102,6 +133,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "optimization",
|
||||
icon: "AppWindow",
|
||||
route: "/favicon",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Adjustments
|
||||
{
|
||||
@@ -111,6 +145,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "adjustments",
|
||||
icon: "SlidersHorizontal",
|
||||
route: "/adjust-colors",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "sharpening",
|
||||
@@ -119,6 +156,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "adjustments",
|
||||
icon: "Focus",
|
||||
route: "/sharpening",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "replace-color",
|
||||
@@ -127,6 +167,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "adjustments",
|
||||
icon: "Pipette",
|
||||
route: "/replace-color",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "color-blindness",
|
||||
@@ -135,6 +178,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "adjustments",
|
||||
icon: "Eye",
|
||||
route: "/color-blindness",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// AI Tools
|
||||
{
|
||||
@@ -144,6 +190,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Eraser",
|
||||
route: "/remove-background",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "upscale",
|
||||
@@ -152,6 +201,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "ZoomIn",
|
||||
route: "/upscale",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "erase-object",
|
||||
@@ -160,6 +212,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Wand",
|
||||
route: "/erase-object",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "ocr",
|
||||
@@ -168,6 +223,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "ScanText",
|
||||
route: "/ocr",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "blur-faces",
|
||||
@@ -176,6 +234,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "EyeOff",
|
||||
route: "/blur-faces",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "smart-crop",
|
||||
@@ -184,6 +245,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Crosshair",
|
||||
route: "/smart-crop",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "image-enhancement",
|
||||
@@ -192,6 +256,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Sparkles",
|
||||
route: "/image-enhancement",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast", // pure sharp/CV; optional deepEnhance defers to noise-removal
|
||||
},
|
||||
{
|
||||
id: "enhance-faces",
|
||||
@@ -200,6 +267,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "ScanFace",
|
||||
route: "/enhance-faces",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "colorize",
|
||||
@@ -208,6 +278,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Palette",
|
||||
route: "/colorize",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "noise-removal",
|
||||
@@ -216,6 +289,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "AudioLines",
|
||||
route: "/noise-removal",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "red-eye-removal",
|
||||
@@ -224,6 +300,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "ScanEye",
|
||||
route: "/red-eye-removal",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "restore-photo",
|
||||
@@ -232,6 +311,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Undo2",
|
||||
route: "/restore-photo",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "passport-photo",
|
||||
@@ -240,6 +322,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "UserCheck",
|
||||
route: "/passport-photo",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "content-aware-resize",
|
||||
@@ -248,6 +333,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Scaling",
|
||||
route: "/content-aware-resize",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "ai-canvas-expand",
|
||||
@@ -256,6 +344,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "Expand",
|
||||
route: "/ai-canvas-expand",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "transparency-fixer",
|
||||
@@ -264,6 +355,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "ai",
|
||||
icon: "ShieldCheck",
|
||||
route: "/transparency-fixer",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
// Watermark & Overlay
|
||||
{
|
||||
@@ -273,6 +367,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "watermark",
|
||||
icon: "Type",
|
||||
route: "/watermark-text",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "watermark-image",
|
||||
@@ -281,6 +378,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "watermark",
|
||||
icon: "Image",
|
||||
route: "/watermark-image",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "text-overlay",
|
||||
@@ -289,6 +389,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "watermark",
|
||||
icon: "TextCursorInput",
|
||||
route: "/text-overlay",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "compose",
|
||||
@@ -297,6 +400,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "watermark",
|
||||
icon: "Layers",
|
||||
route: "/compose",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "meme-generator",
|
||||
@@ -305,6 +411,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "watermark",
|
||||
icon: "Laugh",
|
||||
route: "/meme-generator",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Utilities
|
||||
{
|
||||
@@ -314,6 +423,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "Info",
|
||||
route: "/info",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "compare",
|
||||
@@ -322,6 +434,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "Columns2",
|
||||
route: "/compare",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "find-duplicates",
|
||||
@@ -330,6 +445,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "Copy",
|
||||
route: "/find-duplicates",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "color-palette",
|
||||
@@ -338,6 +456,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "Droplets",
|
||||
route: "/color-palette",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "qr-generate",
|
||||
@@ -346,6 +467,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "QrCode",
|
||||
route: "/qr-generate",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "html-to-image",
|
||||
@@ -354,6 +478,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "Globe",
|
||||
route: "/html-to-image",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "barcode-read",
|
||||
@@ -362,6 +489,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "ScanLine",
|
||||
route: "/barcode-read",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "image-to-base64",
|
||||
@@ -370,6 +500,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "utilities",
|
||||
icon: "Code",
|
||||
route: "/image-to-base64",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Layout & Composition
|
||||
{
|
||||
@@ -379,6 +512,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "layout",
|
||||
icon: "LayoutGrid",
|
||||
route: "/collage",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "stitch",
|
||||
@@ -387,6 +523,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "layout",
|
||||
icon: "Columns2",
|
||||
route: "/stitch",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "split",
|
||||
@@ -395,6 +534,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "layout",
|
||||
icon: "Grid3x3",
|
||||
route: "/split",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "border",
|
||||
@@ -403,6 +545,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "layout",
|
||||
icon: "Frame",
|
||||
route: "/border",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "beautify",
|
||||
@@ -411,6 +556,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "layout",
|
||||
icon: "ImagePlus",
|
||||
route: "/beautify",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Format & Conversion
|
||||
{
|
||||
@@ -420,6 +568,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "format",
|
||||
icon: "FileImage",
|
||||
route: "/svg-to-raster",
|
||||
modality: "image",
|
||||
acceptedInputs: [".svg", ".svgz"],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "vectorize",
|
||||
@@ -428,6 +579,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "format",
|
||||
icon: "PenTool",
|
||||
route: "/vectorize",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "gif-tools",
|
||||
@@ -437,6 +591,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "format",
|
||||
icon: "Film",
|
||||
route: "/gif-tools",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "pdf-to-image",
|
||||
@@ -445,6 +602,9 @@ export const TOOLS: Tool[] = [
|
||||
category: "format",
|
||||
icon: "BookImage",
|
||||
route: "/pdf-to-image",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ export const ar: TranslationKeys = {
|
||||
format: "التنسيق والتحويل",
|
||||
ai: "أدوات AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "تغيير الحجم",
|
||||
@@ -271,6 +277,14 @@ export const ar: TranslationKeys = {
|
||||
description: "ربط عدة أدوات في سير عمل واحد",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const de: TranslationKeys = {
|
||||
format: "Format & Konvertierung",
|
||||
ai: "AI-Werkzeuge",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Groesse aendern",
|
||||
@@ -276,6 +282,14 @@ export const de: TranslationKeys = {
|
||||
description: "Mehrere Werkzeuge zu einem Workflow verketten",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -47,6 +47,12 @@ export const en = {
|
||||
format: "Format & Conversion",
|
||||
ai: "AI Tools",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Resize",
|
||||
@@ -228,6 +234,14 @@ export const en = {
|
||||
},
|
||||
pipeline: { name: "Pipeline Builder", description: "Chain multiple tools into a workflow" },
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const es: TranslationKeys = {
|
||||
format: "Formato y conversion",
|
||||
ai: "Herramientas de AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Redimensionar",
|
||||
@@ -261,6 +267,14 @@ export const es: TranslationKeys = {
|
||||
description: "Encadena multiples herramientas en un flujo de trabajo",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const fr: TranslationKeys = {
|
||||
format: "Format et conversion",
|
||||
ai: "Outils AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Redimensionner",
|
||||
@@ -277,6 +283,14 @@ export const fr: TranslationKeys = {
|
||||
description: "Enchainez plusieurs outils dans un flux de travail",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const hi: TranslationKeys = {
|
||||
format: "फॉर्मेट और कन्वर्शन",
|
||||
ai: "AI टूल्स",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "रीसाइज़",
|
||||
@@ -268,6 +274,14 @@ export const hi: TranslationKeys = {
|
||||
description: "कई टूल्स को एक वर्कफ्लो में चेन करें",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const id: TranslationKeys = {
|
||||
format: "Format & Konversi",
|
||||
ai: "Alat AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Ubah Ukuran",
|
||||
@@ -276,6 +282,14 @@ export const id: TranslationKeys = {
|
||||
description: "Rangkai beberapa alat menjadi alur kerja",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const it: TranslationKeys = {
|
||||
format: "Formato e conversione",
|
||||
ai: "Strumenti IA",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Ridimensiona",
|
||||
@@ -275,6 +281,14 @@ export const it: TranslationKeys = {
|
||||
description: "Concatena piu strumenti in un flusso di lavoro",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const ja: TranslationKeys = {
|
||||
format: "フォーマットと変換",
|
||||
ai: "AIツール",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "リサイズ",
|
||||
@@ -236,6 +242,14 @@ export const ja: TranslationKeys = {
|
||||
},
|
||||
pipeline: { name: "Pipelineビルダー", description: "複数のツールをワークフローに連結" },
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const ko: TranslationKeys = {
|
||||
format: "포맷 및 변환",
|
||||
ai: "AI 도구",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "리사이즈",
|
||||
@@ -223,6 +229,14 @@ export const ko: TranslationKeys = {
|
||||
},
|
||||
pipeline: { name: "Pipeline 빌더", description: "여러 도구를 워크플로로 연결" },
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const nl: TranslationKeys = {
|
||||
format: "Formaat & Conversie",
|
||||
ai: "AI-tools",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Formaat wijzigen",
|
||||
@@ -276,6 +282,14 @@ export const nl: TranslationKeys = {
|
||||
description: "Meerdere tools koppelen tot een workflow",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const pl: TranslationKeys = {
|
||||
format: "Format i konwersja",
|
||||
ai: "Narzędzia AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Zmiana rozmiaru",
|
||||
@@ -277,6 +283,14 @@ export const pl: TranslationKeys = {
|
||||
description: "Łączenie wielu narzędzi w przepływ pracy",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const ptBR: TranslationKeys = {
|
||||
format: "Formato e conversao",
|
||||
ai: "Ferramentas de AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Redimensionar",
|
||||
@@ -274,6 +280,14 @@ export const ptBR: TranslationKeys = {
|
||||
description: "Encadeie varias ferramentas em um fluxo de trabalho",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const ru: TranslationKeys = {
|
||||
format: "Формат и конвертация",
|
||||
ai: "AI-инструменты",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Изменение размера",
|
||||
@@ -276,6 +282,14 @@ export const ru: TranslationKeys = {
|
||||
description: "Объединение нескольких инструментов в рабочий процесс",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const sv: TranslationKeys = {
|
||||
format: "Format & Konvertering",
|
||||
ai: "AI-verktyg",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Andra storlek",
|
||||
@@ -274,6 +280,14 @@ export const sv: TranslationKeys = {
|
||||
description: "Kedja samman flera verktyg till ett arbetsflode",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const th: TranslationKeys = {
|
||||
format: "รูปแบบและการแปลง",
|
||||
ai: "เครื่องมือ AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "ปรับขนาด",
|
||||
@@ -269,6 +275,14 @@ export const th: TranslationKeys = {
|
||||
description: "เชื่อมต่อหลายเครื่องมือเป็นขั้นตอนทำงาน",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const tr: TranslationKeys = {
|
||||
format: "Biçim ve Dönüştürme",
|
||||
ai: "AI Araçları",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Boyutlandır",
|
||||
@@ -277,6 +283,14 @@ export const tr: TranslationKeys = {
|
||||
description: "Birden fazla aracı bir iş akışında zincirleyin",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const uk: TranslationKeys = {
|
||||
format: "Формат і конвертація",
|
||||
ai: "AI-інструменти",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Зміна розміру",
|
||||
@@ -276,6 +282,14 @@ export const uk: TranslationKeys = {
|
||||
description: "Об'єднання кількох інструментів у робочий процес",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const vi: TranslationKeys = {
|
||||
format: "Định dạng & Chuyển đổi",
|
||||
ai: "Công cụ AI",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "Thay đổi kích thước",
|
||||
@@ -277,6 +283,14 @@ export const vi: TranslationKeys = {
|
||||
description: "Kết nối nhiều công cụ thành một quy trình làm việc",
|
||||
},
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const zhCN: TranslationKeys = {
|
||||
format: "格式与转换",
|
||||
ai: "AI 工具",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "调整大小",
|
||||
@@ -223,6 +229,14 @@ export const zhCN: TranslationKeys = {
|
||||
},
|
||||
pipeline: { name: "Pipeline 构建器", description: "将多个工具串联为工作流" },
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -49,6 +49,12 @@ export const zhTW: TranslationKeys = {
|
||||
format: "格式與轉換",
|
||||
ai: "AI工具",
|
||||
},
|
||||
modalities: {
|
||||
image: "Image",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
documentsAndFiles: "Documents & Files",
|
||||
},
|
||||
tools: {
|
||||
resize: {
|
||||
name: "調整大小",
|
||||
@@ -222,6 +228,14 @@ export const zhTW: TranslationKeys = {
|
||||
},
|
||||
pipeline: { name: "Pipeline建構器", description: "將多個工具串聯為工作流程" },
|
||||
processing: { canceled: "Processing canceled" },
|
||||
mediaPlayer: {
|
||||
unsupported: "Your browser does not support this media format.",
|
||||
},
|
||||
documentView: {
|
||||
loadFailed: "Failed to load document.",
|
||||
previousPage: "Previous page",
|
||||
nextPage: "Next page",
|
||||
},
|
||||
},
|
||||
toolSettings: {
|
||||
compress: {
|
||||
|
||||
@@ -4,5 +4,6 @@ export * from "./analytics/types.js";
|
||||
export * from "./constants.js";
|
||||
export * from "./features.js";
|
||||
export * from "./i18n/index.js";
|
||||
export * from "./modality.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./types.js";
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
export type Modality = "image" | "video" | "audio" | "document" | "file";
|
||||
|
||||
export interface ModalityInfo {
|
||||
id: Modality;
|
||||
name: string;
|
||||
icon: string; // lucide icon name, same convention as CategoryInfo
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Five modality values in code; "document" and "file" group as one
|
||||
// "Documents & Files" area in the UI (see spec 4.5).
|
||||
export const MODALITIES: ModalityInfo[] = [
|
||||
{ id: "image", name: "Image", icon: "Image", color: "#3B82F6" },
|
||||
{ id: "video", name: "Video", icon: "Video", color: "#EF4444" },
|
||||
{ id: "audio", name: "Audio", icon: "AudioLines", color: "#10B981" },
|
||||
{ id: "document", name: "Documents", icon: "FileText", color: "#8B5CF6" },
|
||||
{ id: "file", name: "Files", icon: "FileArchive", color: "#F59E0B" },
|
||||
];
|
||||
|
||||
// Which BullMQ pool a modality's tools run on. AI tools override to "ai"
|
||||
// at enqueue time regardless of modality.
|
||||
export const MODALITY_POOL: Record<Modality, "image" | "media" | "docs"> = {
|
||||
image: "image",
|
||||
video: "media",
|
||||
audio: "media",
|
||||
document: "docs",
|
||||
file: "docs",
|
||||
};
|
||||
|
||||
// Default accepted input extensions per modality (with dots; drives the
|
||||
// file picker accept attribute and docs). Tools may narrow this.
|
||||
export const IMAGE_INPUTS = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
".avif",
|
||||
".heic",
|
||||
".heif",
|
||||
".svg",
|
||||
".svgz",
|
||||
".ico",
|
||||
".jxl",
|
||||
".jp2",
|
||||
".psd",
|
||||
".tga",
|
||||
".exr",
|
||||
".hdr",
|
||||
".dng",
|
||||
".cr2",
|
||||
".nef",
|
||||
".arw",
|
||||
".orf",
|
||||
".rw2",
|
||||
".ppm",
|
||||
".pgm",
|
||||
".pbm",
|
||||
".qoi",
|
||||
".dds",
|
||||
".fits",
|
||||
".dpx",
|
||||
".apng",
|
||||
".cur",
|
||||
".eps",
|
||||
];
|
||||
export const VIDEO_INPUTS = [
|
||||
".mp4",
|
||||
".mov",
|
||||
".webm",
|
||||
".mkv",
|
||||
".avi",
|
||||
".m4v",
|
||||
".mts",
|
||||
".m2ts",
|
||||
".3gp",
|
||||
".flv",
|
||||
".wmv",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
".ts",
|
||||
".ogv",
|
||||
];
|
||||
export const AUDIO_INPUTS = [
|
||||
".mp3",
|
||||
".wav",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wma",
|
||||
".aiff",
|
||||
".amr",
|
||||
".ac3",
|
||||
];
|
||||
export const DOCUMENT_INPUTS = [
|
||||
".pdf",
|
||||
".docx",
|
||||
".doc",
|
||||
".xlsx",
|
||||
".xls",
|
||||
".pptx",
|
||||
".ppt",
|
||||
".odt",
|
||||
".ods",
|
||||
".odp",
|
||||
".rtf",
|
||||
".txt",
|
||||
".md",
|
||||
".html",
|
||||
".epub",
|
||||
".mobi",
|
||||
".azw3",
|
||||
];
|
||||
export const FILE_INPUTS = [".csv", ".json", ".xml", ".yaml", ".yml", ".zip"];
|
||||
|
||||
export function detectModalityFromMime(mime: string): Modality {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (
|
||||
mime === "application/pdf" ||
|
||||
mime.includes("officedocument") ||
|
||||
mime.includes("msword") ||
|
||||
mime.includes("ms-excel") ||
|
||||
mime.includes("ms-powerpoint") ||
|
||||
mime === "application/epub+zip" ||
|
||||
mime.startsWith("text/html")
|
||||
) {
|
||||
return "document";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Modality } from "./modality.js";
|
||||
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -5,6 +7,10 @@ export interface Tool {
|
||||
category: ToolCategory;
|
||||
icon: string;
|
||||
route: string;
|
||||
modality: Modality;
|
||||
acceptedInputs: string[];
|
||||
executionHint: "fast" | "long";
|
||||
maxInputSizeMB?: number;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
experimental?: boolean;
|
||||
|
||||
Generated
+93
-118
@@ -146,12 +146,18 @@ importers:
|
||||
'@snapotter/ai':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ai
|
||||
'@snapotter/doc-engine':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/doc-engine
|
||||
'@snapotter/enterprise':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/enterprise
|
||||
'@snapotter/image-engine':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/image-engine
|
||||
'@snapotter/media-engine':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/media-engine
|
||||
'@snapotter/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
@@ -206,9 +212,6 @@ importers:
|
||||
pino-roll:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
piscina:
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4
|
||||
playwright:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
@@ -398,6 +401,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.7)
|
||||
pdfjs-dist:
|
||||
specifier: ^6.0.227
|
||||
version: 6.0.227
|
||||
posthog-js:
|
||||
specifier: ^1.377.0
|
||||
version: 1.379.2
|
||||
@@ -482,6 +488,22 @@ importers:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/doc-engine:
|
||||
dependencies:
|
||||
'@snapotter/ai':
|
||||
specifier: workspace:*
|
||||
version: link:../ai
|
||||
'@snapotter/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)
|
||||
|
||||
packages/enterprise:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
@@ -523,6 +545,19 @@ importers:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)
|
||||
|
||||
packages/media-engine:
|
||||
dependencies:
|
||||
'@snapotter/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(@types/debug@4.1.13)(@types/node@25.8.0)(esbuild@0.28.0)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)
|
||||
|
||||
packages/shared:
|
||||
devDependencies:
|
||||
typescript:
|
||||
@@ -2352,110 +2387,74 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/nice-android-arm-eabi@1.1.1':
|
||||
resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/nice-android-arm64@1.1.1':
|
||||
resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==}
|
||||
'@napi-rs/canvas-android-arm64@1.0.0':
|
||||
resolution: {integrity: sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/nice-darwin-arm64@1.1.1':
|
||||
resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==}
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.0':
|
||||
resolution: {integrity: sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/nice-darwin-x64@1.1.1':
|
||||
resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==}
|
||||
'@napi-rs/canvas-darwin-x64@1.0.0':
|
||||
resolution: {integrity: sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/nice-freebsd-x64@1.1.1':
|
||||
resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
|
||||
resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==}
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.0':
|
||||
resolution: {integrity: sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-linux-arm64-gnu@1.1.1':
|
||||
resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==}
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-linux-arm64-musl@1.1.1':
|
||||
resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==}
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.0':
|
||||
resolution: {integrity: sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-linux-ppc64-gnu@1.1.1':
|
||||
resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-linux-riscv64-gnu@1.1.1':
|
||||
resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==}
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-linux-s390x-gnu@1.1.1':
|
||||
resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-linux-x64-gnu@1.1.1':
|
||||
resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==}
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.0':
|
||||
resolution: {integrity: sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-linux-x64-musl@1.1.1':
|
||||
resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==}
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.0':
|
||||
resolution: {integrity: sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/nice-openharmony-arm64@1.1.1':
|
||||
resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@napi-rs/nice-win32-arm64-msvc@1.1.1':
|
||||
resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==}
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.0':
|
||||
resolution: {integrity: sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/nice-win32-ia32-msvc@1.1.1':
|
||||
resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/nice-win32-x64-msvc@1.1.1':
|
||||
resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==}
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.0':
|
||||
resolution: {integrity: sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/nice@1.1.1':
|
||||
resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==}
|
||||
'@napi-rs/canvas@1.0.0':
|
||||
resolution: {integrity: sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
@@ -5899,6 +5898,10 @@ packages:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
pdfjs-dist@6.0.227:
|
||||
resolution: {integrity: sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
pdfkit@0.18.0:
|
||||
resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==}
|
||||
|
||||
@@ -5972,10 +5975,6 @@ packages:
|
||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||
hasBin: true
|
||||
|
||||
piscina@5.1.4:
|
||||
resolution: {integrity: sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==}
|
||||
engines: {node: '>=20.x'}
|
||||
|
||||
pixelmatch@4.0.2:
|
||||
resolution: {integrity: sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==}
|
||||
hasBin: true
|
||||
@@ -9295,76 +9294,52 @@ snapshots:
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-android-arm-eabi@1.1.1':
|
||||
'@napi-rs/canvas-android-arm64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-android-arm64@1.1.1':
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-darwin-arm64@1.1.1':
|
||||
'@napi-rs/canvas-darwin-x64@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-darwin-x64@1.1.1':
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-freebsd-x64@1.1.1':
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-arm64-gnu@1.1.1':
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-arm64-musl@1.1.1':
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-ppc64-gnu@1.1.1':
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-riscv64-gnu@1.1.1':
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-s390x-gnu@1.1.1':
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.0':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-x64-gnu@1.1.1':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-linux-x64-musl@1.1.1':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-openharmony-arm64@1.1.1':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-win32-arm64-msvc@1.1.1':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-win32-ia32-msvc@1.1.1':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice-win32-x64-msvc@1.1.1':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/nice@1.1.1':
|
||||
'@napi-rs/canvas@1.0.0':
|
||||
optionalDependencies:
|
||||
'@napi-rs/nice-android-arm-eabi': 1.1.1
|
||||
'@napi-rs/nice-android-arm64': 1.1.1
|
||||
'@napi-rs/nice-darwin-arm64': 1.1.1
|
||||
'@napi-rs/nice-darwin-x64': 1.1.1
|
||||
'@napi-rs/nice-freebsd-x64': 1.1.1
|
||||
'@napi-rs/nice-linux-arm-gnueabihf': 1.1.1
|
||||
'@napi-rs/nice-linux-arm64-gnu': 1.1.1
|
||||
'@napi-rs/nice-linux-arm64-musl': 1.1.1
|
||||
'@napi-rs/nice-linux-ppc64-gnu': 1.1.1
|
||||
'@napi-rs/nice-linux-riscv64-gnu': 1.1.1
|
||||
'@napi-rs/nice-linux-s390x-gnu': 1.1.1
|
||||
'@napi-rs/nice-linux-x64-gnu': 1.1.1
|
||||
'@napi-rs/nice-linux-x64-musl': 1.1.1
|
||||
'@napi-rs/nice-openharmony-arm64': 1.1.1
|
||||
'@napi-rs/nice-win32-arm64-msvc': 1.1.1
|
||||
'@napi-rs/nice-win32-ia32-msvc': 1.1.1
|
||||
'@napi-rs/nice-win32-x64-msvc': 1.1.1
|
||||
'@napi-rs/canvas-android-arm64': 1.0.0
|
||||
'@napi-rs/canvas-darwin-arm64': 1.0.0
|
||||
'@napi-rs/canvas-darwin-x64': 1.0.0
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.0
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 1.0.0
|
||||
'@napi-rs/canvas-linux-arm64-musl': 1.0.0
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.0
|
||||
'@napi-rs/canvas-linux-x64-gnu': 1.0.0
|
||||
'@napi-rs/canvas-linux-x64-musl': 1.0.0
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 1.0.0
|
||||
'@napi-rs/canvas-win32-x64-msvc': 1.0.0
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
@@ -12900,6 +12875,10 @@ snapshots:
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
pdfjs-dist@6.0.227:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 1.0.0
|
||||
|
||||
pdfkit@0.18.0:
|
||||
dependencies:
|
||||
'@noble/ciphers': 1.3.0
|
||||
@@ -12985,10 +12964,6 @@ snapshots:
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 4.0.0
|
||||
|
||||
piscina@5.1.4:
|
||||
optionalDependencies:
|
||||
'@napi-rs/nice': 1.1.1
|
||||
|
||||
pixelmatch@4.0.2:
|
||||
dependencies:
|
||||
pngjs: 3.4.0
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates the tiny media/document fixtures committed under tests/fixtures/.
|
||||
* Requires ffmpeg on PATH (or FFMPEG_PATH). Run once; outputs are committed.
|
||||
* node scripts/generate-test-fixtures.mjs
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
const mediaDir = join(root, "tests/fixtures/media");
|
||||
const docsDir = join(root, "tests/fixtures/documents");
|
||||
mkdirSync(mediaDir, { recursive: true });
|
||||
mkdirSync(docsDir, { recursive: true });
|
||||
|
||||
const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg";
|
||||
|
||||
function run(args) {
|
||||
const res = spawnSync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", ...args], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (res.status !== 0) {
|
||||
console.error(`ffmpeg failed: ${args.join(" ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 1s 64x64 silent mp4 (h264 baseline, tiny)
|
||||
run(["-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=8",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
|
||||
join(mediaDir, "tiny.mp4")]);
|
||||
// 1s sine mp3
|
||||
run(["-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "libmp3lame", "-b:a", "32k",
|
||||
join(mediaDir, "tiny.mp3")]);
|
||||
// 1s sine wav
|
||||
run(["-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "pcm_s16le", "-ar", "8000",
|
||||
join(mediaDir, "tiny.wav")]);
|
||||
|
||||
// Minimal OOXML/EPUB containers via archiver (resolved from apps/api).
|
||||
const apiRequire = createRequire(join(root, "apps/api/package.json"));
|
||||
const archiver = apiRequire("archiver");
|
||||
|
||||
async function writeZip(outPath, entries, firstStored) {
|
||||
const { createWriteStream } = await import("node:fs");
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
const out = createWriteStream(outPath);
|
||||
out.on("close", resolvePromise);
|
||||
archive.on("error", reject);
|
||||
archive.pipe(out);
|
||||
if (firstStored) archive.append(firstStored.content, { name: firstStored.name, store: true });
|
||||
for (const [name, content] of Object.entries(entries)) archive.append(content, { name });
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
const docxDocument = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>SnapOtter test document</w:t></w:r></w:p></w:body></w:document>`;
|
||||
const docxContentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`;
|
||||
const docxRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`;
|
||||
|
||||
const xlsxSheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>SnapOtter</t></is></c></row></sheetData></worksheet>`;
|
||||
const xlsxWorkbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
const xlsxWorkbookRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`;
|
||||
const xlsxContentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`;
|
||||
const xlsxRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
|
||||
|
||||
const epubContainer = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`;
|
||||
const epubOpf = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">snapotter-test</dc:identifier><dc:title>Test</dc:title><dc:language>en</dc:language></metadata><manifest><item id="c" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="c"/></spine></package>`;
|
||||
const epubChapter = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Test</title></head><body><p>SnapOtter test epub</p></body></html>`;
|
||||
|
||||
await writeZip(join(docsDir, "tiny.docx"), {
|
||||
"[Content_Types].xml": docxContentTypes,
|
||||
"_rels/.rels": docxRels,
|
||||
"word/document.xml": docxDocument,
|
||||
});
|
||||
await writeZip(join(docsDir, "tiny.xlsx"), {
|
||||
"[Content_Types].xml": xlsxContentTypes,
|
||||
"_rels/.rels": xlsxRels,
|
||||
"xl/workbook.xml": xlsxWorkbook,
|
||||
"xl/_rels/workbook.xml.rels": xlsxWorkbookRels,
|
||||
"xl/worksheets/sheet1.xml": xlsxSheet,
|
||||
});
|
||||
// EPUB requires the mimetype entry FIRST and STORED (uncompressed).
|
||||
await writeZip(
|
||||
join(docsDir, "tiny.epub"),
|
||||
{ "META-INF/container.xml": epubContainer, "OEBPS/content.opf": epubOpf, "OEBPS/chapter.xhtml": epubChapter },
|
||||
{ name: "mimetype", content: "application/epub+zip" },
|
||||
);
|
||||
|
||||
console.log("Fixtures written to tests/fixtures/media and tests/fixtures/documents");
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,49 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
convertDocument,
|
||||
qpdfAvailable,
|
||||
qpdfCheck,
|
||||
qpdfPageCount,
|
||||
sofficeAvailable,
|
||||
} from "@snapotter/doc-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PDF = join(process.cwd(), "tests/fixtures/test-3page.pdf");
|
||||
const DOCX = join(process.cwd(), "tests/fixtures/documents/tiny.docx");
|
||||
|
||||
describe.skipIf(!qpdfAvailable())("doc-engine qpdf (requires qpdf)", () => {
|
||||
it("counts pages", async () => {
|
||||
expect(await qpdfPageCount(PDF)).toBe(3);
|
||||
});
|
||||
|
||||
it("passes a structural check on a valid pdf", async () => {
|
||||
await expect(qpdfCheck(PDF)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects garbage", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "doc-engine-"));
|
||||
try {
|
||||
const bad = join(dir, "bad.pdf");
|
||||
writeFileSync(bad, "not a pdf at all");
|
||||
await expect(qpdfCheck(bad)).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!sofficeAvailable())("doc-engine libreoffice (requires soffice)", () => {
|
||||
it("converts docx to pdf with an isolated profile", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "doc-engine-lo-"));
|
||||
try {
|
||||
const outPath = await convertDocument(DOCX, dir, "pdf", { timeoutMs: 120_000 });
|
||||
const bytes = await readFile(outPath);
|
||||
expect(bytes.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 150_000);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getDocsDispatcher, runDocsScript, shutdownDocsDispatcher } from "@snapotter/ai";
|
||||
import { pdfPageCountPy } from "@snapotter/doc-engine";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Resolve a Python 3 binary path that actually exists.
|
||||
* Checks the configured venv first, then falls back to system python3.
|
||||
* Returns null when no usable python3 is found.
|
||||
*/
|
||||
function resolvePython(): string | null {
|
||||
const venv = process.env.PYTHON_VENV_PATH || join(process.cwd(), ".venv");
|
||||
if (existsSync(`${venv}/bin/python3`)) return `${venv}/bin/python3`;
|
||||
// Fall back: find system python3 and derive a "venv" path the bridge accepts
|
||||
const res = spawnSync("which", ["python3"], { encoding: "utf8" });
|
||||
if (res.status === 0 && res.stdout.trim()) {
|
||||
const bin = res.stdout.trim();
|
||||
// python3 at /opt/homebrew/bin/python3 -> venv prefix = /opt/homebrew
|
||||
const parts = bin.split("/");
|
||||
if (parts.length >= 3) {
|
||||
const prefix = parts.slice(0, -2).join("/");
|
||||
if (existsSync(`${prefix}/bin/python3`)) return `${prefix}/bin/python3`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pythonHasPikepdf(pythonBin: string): boolean {
|
||||
const res = spawnSync(pythonBin, ["-c", "import pikepdf"], { encoding: "utf8" });
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
const pythonBin = resolvePython();
|
||||
const hasPython = pythonBin !== null;
|
||||
const hasPikepdf = hasPython && pythonHasPikepdf(pythonBin!);
|
||||
|
||||
// Ensure the bridge uses a reachable Python; set PYTHON_VENV_PATH before
|
||||
// any import triggers a dispatcher spawn.
|
||||
if (hasPython && !existsSync(join(process.cwd(), ".venv", "bin", "python3"))) {
|
||||
const parts = pythonBin!.split("/");
|
||||
process.env.PYTHON_VENV_PATH = parts.slice(0, -2).join("/");
|
||||
}
|
||||
|
||||
if (!hasPython) console.log("[docs-dispatcher] SKIP: no python3 found (venv or system)");
|
||||
if (hasPython && !hasPikepdf)
|
||||
console.log("[docs-dispatcher] pikepdf not available; pikepdf tests will skip");
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownDocsDispatcher();
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPython)("docs dispatcher health probe", () => {
|
||||
it("runs doc_health through the docs-profile dispatcher", async () => {
|
||||
const result = await runDocsScript("doc_health", {});
|
||||
const parsed = JSON.parse(result.trim());
|
||||
expect(parsed).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("getDocsDispatcher returns a PythonDispatcher instance", () => {
|
||||
const dispatcher = getDocsDispatcher();
|
||||
expect(dispatcher).toBeDefined();
|
||||
expect(typeof dispatcher.run).toBe("function");
|
||||
expect(typeof dispatcher.shutdown).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPikepdf)("docs dispatcher pikepdf (requires python + pikepdf)", () => {
|
||||
it("counts pdf pages through the docs profile", async () => {
|
||||
const pages = await pdfPageCountPy(join(process.cwd(), "tests/fixtures/test-3page.pdf"));
|
||||
expect(pages).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia, runFfmpeg } from "@snapotter/media-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FIXTURE = join(process.cwd(), "tests/fixtures/media/tiny.mp4");
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("media-engine (requires ffmpeg)", () => {
|
||||
it("probes the mp4 fixture with caps", async () => {
|
||||
const info = await probeMedia(FIXTURE);
|
||||
expect(info.durationS).toBeGreaterThan(0.5);
|
||||
expect(info.durationS).toBeLessThan(3);
|
||||
expect(info.streams.some((s) => s.type === "video")).toBe(true);
|
||||
});
|
||||
|
||||
it("transcodes with progress events", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "media-engine-"));
|
||||
try {
|
||||
const out = join(dir, "out.webm");
|
||||
const events: number[] = [];
|
||||
await runFfmpeg(["-i", FIXTURE, "-c:v", "libvpx-vp9", "-deadline", "realtime", out], {
|
||||
timeoutMs: 60_000,
|
||||
onProgress: (p) => {
|
||||
if (p.outTimeMs !== null) events.push(p.outTimeMs);
|
||||
},
|
||||
});
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const info = await probeMedia(out);
|
||||
expect(info.streams.some((s) => s.codec === "vp9")).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails cleanly when onProgress throws", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "media-engine-"));
|
||||
try {
|
||||
const out = join(dir, "out.mp4");
|
||||
await expect(
|
||||
runFfmpeg(["-i", FIXTURE, "-c:v", "libx264", "-preset", "ultrafast", out], {
|
||||
timeoutMs: 60_000,
|
||||
onProgress: () => {
|
||||
throw new Error("callback boom");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("callback boom");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects on abort", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "media-engine-"));
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
runFfmpeg(["-i", FIXTURE, "-c:v", "libx264", join(dir, "x.mp4")], { signal: ac.signal }),
|
||||
).rejects.toThrow(/Canceled/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { qpdfAvailable } from "@snapotter/doc-engine";
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { InputValidationError } from "../../apps/api/src/modality/contract.js";
|
||||
import { DocumentInputHandler } from "../../apps/api/src/modality/document-input.js";
|
||||
import { MediaInputHandler } from "../../apps/api/src/modality/media-input.js";
|
||||
|
||||
const scratchDir = mkdtempSync(join(tmpdir(), "modality-input-"));
|
||||
afterAll(() => rmSync(scratchDir, { recursive: true, force: true }));
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("MediaInputHandler (requires ffmpeg)", () => {
|
||||
it("accepts the mp4 fixture as video", async () => {
|
||||
const buf = await readFile(join(process.cwd(), "tests/fixtures/media/tiny.mp4"));
|
||||
const out = await new MediaInputHandler("video").prepare(buf, "tiny.mp4", { scratchDir });
|
||||
expect(out.buffer).toBe(buf);
|
||||
});
|
||||
|
||||
it("rejects an audio-only file as video", async () => {
|
||||
const buf = await readFile(join(process.cwd(), "tests/fixtures/media/tiny.mp3"));
|
||||
await expect(
|
||||
new MediaInputHandler("video").prepare(buf, "fake.mp4", { scratchDir }),
|
||||
).rejects.toThrow(InputValidationError);
|
||||
});
|
||||
|
||||
it("enforces the duration cap", async () => {
|
||||
const { env } = await import("../../apps/api/src/config.js");
|
||||
const original = env.MAX_AUDIO_DURATION_S;
|
||||
(env as Record<string, unknown>).MAX_AUDIO_DURATION_S = 0.5;
|
||||
try {
|
||||
const buf = await readFile(join(process.cwd(), "tests/fixtures/media/tiny.mp3"));
|
||||
await expect(
|
||||
new MediaInputHandler("audio").prepare(buf, "tiny.mp3", { scratchDir }),
|
||||
).rejects.toThrow(/exceeds the maximum/);
|
||||
} finally {
|
||||
(env as Record<string, unknown>).MAX_AUDIO_DURATION_S = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DocumentInputHandler", () => {
|
||||
it("rejects a non-pdf with pdf extension", async () => {
|
||||
await expect(
|
||||
new DocumentInputHandler().prepare(Buffer.from("hello"), "fake.pdf", { scratchDir }),
|
||||
).rejects.toThrow(/PDF header/);
|
||||
});
|
||||
|
||||
it.skipIf(!qpdfAvailable())("accepts the 3-page fixture and enforces page caps", async () => {
|
||||
const buf = await readFile(join(process.cwd(), "tests/fixtures/test-3page.pdf"));
|
||||
const out = await new DocumentInputHandler().prepare(buf, "test.pdf", { scratchDir });
|
||||
expect(out.buffer).toBe(buf);
|
||||
const { env } = await import("../../apps/api/src/config.js");
|
||||
const original = env.MAX_PDF_PAGES;
|
||||
(env as Record<string, unknown>).MAX_PDF_PAGES = 2;
|
||||
try {
|
||||
await expect(
|
||||
new DocumentInputHandler().prepare(buf, "test.pdf", { scratchDir }),
|
||||
).rejects.toThrow(/exceeding the maximum/);
|
||||
} finally {
|
||||
(env as Record<string, unknown>).MAX_PDF_PAGES = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the docx fixture container", async () => {
|
||||
const buf = await readFile(join(process.cwd(), "tests/fixtures/documents/tiny.docx"));
|
||||
const out = await new DocumentInputHandler().prepare(buf, "tiny.docx", { scratchDir });
|
||||
expect(out.buffer).toBe(buf);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveToolPool, shouldSkipSyncWindow } from "../../apps/api/src/lib/pool.js";
|
||||
|
||||
describe("pool routing", () => {
|
||||
it("image tools stay on the image pool", () => {
|
||||
expect(resolveToolPool("resize")).toBe("image");
|
||||
});
|
||||
it("ai tools route to the ai pool regardless of modality", () => {
|
||||
expect(resolveToolPool("remove-background")).toBe("ai");
|
||||
});
|
||||
it("unknown tools default to image", () => {
|
||||
expect(resolveToolPool("not-a-tool")).toBe("image");
|
||||
});
|
||||
it("long hint skips the sync window", () => {
|
||||
expect(shouldSkipSyncWindow("long")).toBe(true);
|
||||
expect(shouldSkipSyncWindow("fast")).toBe(false);
|
||||
expect(shouldSkipSyncWindow(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { resolveGs } from "@snapotter/doc-engine";
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pdfFirstPagePreview, videoPosterPreview } from "../../apps/api/src/modality/preview.js";
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("video poster preview (requires ffmpeg)", () => {
|
||||
it("renders a webp poster", async () => {
|
||||
const buf = await readFile(join(process.cwd(), "tests/fixtures/media/tiny.mp4"));
|
||||
const poster = await videoPosterPreview(buf);
|
||||
expect(poster).not.toBeNull();
|
||||
expect(poster?.length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!resolveGs())("pdf first-page preview (requires ghostscript)", () => {
|
||||
it("renders a png of page 1", async () => {
|
||||
const buf = await readFile(join(process.cwd(), "tests/fixtures/test-3page.pdf"));
|
||||
const png = await pdfFirstPagePreview(buf);
|
||||
expect(png).not.toBeNull();
|
||||
expect(png?.subarray(1, 4).toString()).toBe("PNG");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Integration tests for the ref-based v2 process contract.
|
||||
*
|
||||
* Registers a native-v2 tool that receives TWO input refs and writes
|
||||
* a scratch-dir output (concatenation of both inputs). Verifies the
|
||||
* worker loads all refs, invokes processV2 (not the legacy path), and
|
||||
* resolves scratchPath outputs correctly.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../../apps/api/src/db/index.js";
|
||||
import { enqueueToolJob, waitForJob } from "../../apps/api/src/jobs/enqueue.js";
|
||||
import type { ToolJobData } from "../../apps/api/src/jobs/types.js";
|
||||
import { getObjectBuffer, putObject } from "../../apps/api/src/lib/object-storage.js";
|
||||
import { registerToolProcessFn } from "../../apps/api/src/routes/tool-factory.js";
|
||||
import { buildTestApp, type TestApp } from "./test-server.js";
|
||||
|
||||
// Register a native-v2 test tool that concatenates two inputs via scratchPath
|
||||
registerToolProcessFn({
|
||||
toolId: "contract-v2",
|
||||
settingsSchema: { parse: (v: unknown) => v } as never,
|
||||
process: async () => {
|
||||
throw new Error("legacy path must not run");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length !== 2) throw new Error(`expected 2 inputs, got ${ctx.inputs.length}`);
|
||||
const outPath = join(ctx.scratchDir, "combined.txt");
|
||||
await writeFile(outPath, Buffer.concat(ctx.inputs.map((i) => i.buffer)));
|
||||
return { scratchPath: outPath, filename: "combined.txt", contentType: "text/plain" };
|
||||
},
|
||||
});
|
||||
|
||||
// Register a legacy-only test tool (no processV2) to verify the adapter
|
||||
registerToolProcessFn({
|
||||
toolId: "contract-legacy-echo",
|
||||
settingsSchema: { parse: (v: unknown) => v } as never,
|
||||
process: async (inputBuffer: Buffer, _settings: unknown, filename: string) => {
|
||||
return { buffer: inputBuffer, filename, contentType: "application/octet-stream" };
|
||||
},
|
||||
});
|
||||
|
||||
let testApp: TestApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("V2 process contract", () => {
|
||||
it("loads all inputRefs and invokes processV2 with scratchPath output", async () => {
|
||||
const jobId = randomUUID();
|
||||
const bufA = Buffer.from("AAAA");
|
||||
const bufB = Buffer.from("BBBB");
|
||||
|
||||
// Store two inputs in object storage
|
||||
const refA = `uploads/${jobId}/input-a.txt`;
|
||||
const refB = `uploads/${jobId}/input-b.txt`;
|
||||
await putObject(refA, bufA);
|
||||
await putObject(refB, bufB);
|
||||
|
||||
const data: ToolJobData = {
|
||||
jobId,
|
||||
toolId: "contract-v2",
|
||||
userId: null,
|
||||
pool: "image",
|
||||
inputRefs: [refA, refB],
|
||||
filename: "input-a.txt",
|
||||
settings: {},
|
||||
kind: "tool",
|
||||
};
|
||||
|
||||
await enqueueToolJob(data);
|
||||
|
||||
const result = await waitForJob("image", jobId, 10_000);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.filename).toBe("combined.txt");
|
||||
expect(result!.outputRefs.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify the output is the concatenation of both inputs
|
||||
const outputBuffer = await getObjectBuffer(result!.outputRefs[0]);
|
||||
expect(outputBuffer.toString()).toBe("AAAABBBB");
|
||||
|
||||
// Verify sizes: original is the primary input, processed is the concatenated output
|
||||
expect(result!.originalSize).toBe(4); // bufA.length
|
||||
expect(result!.processedSize).toBe(8); // bufA.length + bufB.length
|
||||
|
||||
// Verify the durable DB row
|
||||
const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.status).toBe("completed");
|
||||
expect(row!.bytesIn).toBe(4);
|
||||
expect(row!.bytesOut).toBe(8);
|
||||
expect(row!.outputRefs).toBeDefined();
|
||||
expect((row!.outputRefs as string[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes input refs and filenames correctly to processV2", async () => {
|
||||
const jobId = randomUUID();
|
||||
const bufA = Buffer.from("first");
|
||||
const bufB = Buffer.from("second");
|
||||
|
||||
const refA = `uploads/${jobId}/primary.bin`;
|
||||
const refB = `uploads/${jobId}/secondary.bin`;
|
||||
await putObject(refA, bufA);
|
||||
await putObject(refB, bufB);
|
||||
|
||||
// Register a tool that returns metadata about what it received
|
||||
registerToolProcessFn({
|
||||
toolId: "contract-v2-meta",
|
||||
settingsSchema: { parse: (v: unknown) => v } as never,
|
||||
process: async () => {
|
||||
throw new Error("legacy path must not run");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const meta = {
|
||||
count: ctx.inputs.length,
|
||||
refs: ctx.inputs.map((i) => i.ref),
|
||||
filenames: ctx.inputs.map((i) => i.filename),
|
||||
sizes: ctx.inputs.map((i) => i.buffer.length),
|
||||
};
|
||||
return {
|
||||
buffer: Buffer.from(JSON.stringify(meta)),
|
||||
filename: "meta.json",
|
||||
contentType: "application/json",
|
||||
resultPayload: meta,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const data: ToolJobData = {
|
||||
jobId,
|
||||
toolId: "contract-v2-meta",
|
||||
userId: null,
|
||||
pool: "image",
|
||||
inputRefs: [refA, refB],
|
||||
filename: "my-file.bin",
|
||||
settings: {},
|
||||
kind: "tool",
|
||||
};
|
||||
|
||||
await enqueueToolJob(data);
|
||||
|
||||
const result = await waitForJob("image", jobId, 10_000);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
// Primary filename is data.filename, secondary derives from the ref basename
|
||||
expect(result!.resultPayload).toBeDefined();
|
||||
const meta = result!.resultPayload as {
|
||||
count: number;
|
||||
refs: string[];
|
||||
filenames: string[];
|
||||
sizes: number[];
|
||||
};
|
||||
expect(meta.count).toBe(2);
|
||||
expect(meta.refs).toEqual([refA, refB]);
|
||||
expect(meta.filenames[0]).toBe("my-file.bin"); // primary keeps client name
|
||||
expect(meta.filenames[1]).toBe("secondary.bin"); // derived from ref basename
|
||||
expect(meta.sizes).toEqual([5, 6]);
|
||||
});
|
||||
|
||||
it("legacy adapter routes single-input tools through processV2", async () => {
|
||||
const jobId = randomUUID();
|
||||
const buf = Buffer.from("echo-me");
|
||||
|
||||
const ref = `uploads/${jobId}/test.bin`;
|
||||
await putObject(ref, buf);
|
||||
|
||||
// contract-legacy-echo was registered with only a legacy process
|
||||
// function. registerToolProcessFn should have wrapped it via
|
||||
// adaptLegacyProcess so the worker calls processV2 internally.
|
||||
const data: ToolJobData = {
|
||||
jobId,
|
||||
toolId: "contract-legacy-echo",
|
||||
userId: null,
|
||||
pool: "image",
|
||||
inputRefs: [ref],
|
||||
filename: "test.bin",
|
||||
settings: {},
|
||||
kind: "tool",
|
||||
};
|
||||
|
||||
await enqueueToolJob(data);
|
||||
|
||||
const result = await waitForJob("image", jobId, 10_000);
|
||||
expect(result).not.toBeNull();
|
||||
const outputBuffer = await getObjectBuffer(result!.outputRefs[0]);
|
||||
expect(outputBuffer.toString()).toBe("echo-me");
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
}, 60_000);
|
||||
|
||||
it("accepts default settings (subject mode)", async () => {
|
||||
@@ -75,7 +75,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const result = JSON.parse(res.body);
|
||||
@@ -108,7 +108,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const result = JSON.parse(res.body);
|
||||
@@ -147,7 +147,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
}, 60_000);
|
||||
|
||||
it("subject mode with padding", async () => {
|
||||
@@ -169,7 +169,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const result = JSON.parse(res.body);
|
||||
@@ -208,7 +208,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
}, 60_000);
|
||||
|
||||
it("trim mode removes whitespace", async () => {
|
||||
@@ -232,7 +232,7 @@ describe("Smart Crop", () => {
|
||||
});
|
||||
|
||||
// trim on a blank image may 422 or succeed with a tiny result
|
||||
expect([200, 422, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 422, 501]).toContain(res.statusCode);
|
||||
}, 60_000);
|
||||
|
||||
it("trim mode with padToSquare and targetSize", async () => {
|
||||
@@ -259,7 +259,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const result = JSON.parse(res.body);
|
||||
@@ -296,7 +296,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 501]).toContain(res.statusCode);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
@@ -317,7 +317,7 @@ describe("Smart Crop", () => {
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 422, 501]).toContain(res.statusCode);
|
||||
expect([200, 202, 422, 501]).toContain(res.statusCode);
|
||||
}, 60_000);
|
||||
|
||||
// ── Validation (always testable) ─────────────────────────────────
|
||||
|
||||
@@ -103,6 +103,24 @@ vi.mock("../../../apps/api/src/lib/feature-status.js", () => ({
|
||||
isToolInstalled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Media/document handlers are loaded transitively via input-handler.ts
|
||||
vi.mock("@snapotter/media-engine", () => ({
|
||||
probeMedia: vi.fn(),
|
||||
ffmpegAvailable: vi.fn(() => false),
|
||||
resolveFfmpeg: vi.fn(() => null),
|
||||
resolveFfprobe: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@snapotter/doc-engine", () => ({
|
||||
qpdfAvailable: vi.fn(() => false),
|
||||
qpdfCheck: vi.fn(),
|
||||
qpdfPageCount: vi.fn(),
|
||||
sofficeAvailable: vi.fn(() => false),
|
||||
resolveQpdf: vi.fn(() => null),
|
||||
resolveSoffice: vi.fn(() => null),
|
||||
resolveGs: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/errors.js", () => ({
|
||||
formatZodErrors: (issues: Array<{ message: string }>) => issues.map((i) => i.message).join("; "),
|
||||
stripInternalPaths: (msg: string) => msg,
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Unit tests for the worker pool module.
|
||||
*
|
||||
* Tests the getWorkerPool singleton behavior, shutdownWorkerPool cleanup,
|
||||
* and the image-worker interface types.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockDestroy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("piscina", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
run: vi.fn(),
|
||||
destroy: mockDestroy,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/env.js", () => ({
|
||||
loadEnv: () => ({ MAX_WORKER_THREADS: 2 }),
|
||||
resolveWorkerThreads: () => 2,
|
||||
}));
|
||||
|
||||
import { getWorkerPool, shutdownWorkerPool } from "../../../apps/api/src/lib/worker-pool.js";
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("worker-pool", () => {
|
||||
afterEach(async () => {
|
||||
// Clean up the pool singleton between tests
|
||||
await shutdownWorkerPool();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getWorkerPool", () => {
|
||||
it("returns a Piscina instance", () => {
|
||||
const pool = getWorkerPool();
|
||||
expect(pool).toBeDefined();
|
||||
expect(pool.run).toBeDefined();
|
||||
expect(pool.destroy).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns the same instance on repeated calls (singleton)", () => {
|
||||
const pool1 = getWorkerPool();
|
||||
const pool2 = getWorkerPool();
|
||||
expect(pool1).toBe(pool2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shutdownWorkerPool", () => {
|
||||
it("destroys the pool", async () => {
|
||||
const pool = getWorkerPool();
|
||||
const destroySpy = vi.spyOn(pool, "destroy");
|
||||
await shutdownWorkerPool();
|
||||
expect(destroySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can be called when no pool exists (no-op)", async () => {
|
||||
// Do not create pool, just shut down -- should not throw
|
||||
await shutdownWorkerPool();
|
||||
});
|
||||
|
||||
it("creates a fresh pool after shutdown", async () => {
|
||||
const pool1 = getWorkerPool();
|
||||
await shutdownWorkerPool();
|
||||
const pool2 = getWorkerPool();
|
||||
expect(pool2).not.toBe(pool1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("image-worker interface", () => {
|
||||
// The WorkerInput and WorkerOutput types define the contract
|
||||
// for image processing in worker threads. We test the shape here.
|
||||
|
||||
it("WorkerInput has the expected shape", () => {
|
||||
const input = {
|
||||
toolId: "resize",
|
||||
inputBuffer: Buffer.from("test"),
|
||||
settings: { width: 100 },
|
||||
filename: "photo.jpg",
|
||||
inputFormat: "jpeg",
|
||||
};
|
||||
|
||||
expect(input.toolId).toBe("resize");
|
||||
expect(Buffer.isBuffer(input.inputBuffer)).toBe(true);
|
||||
expect(input.settings).toEqual({ width: 100 });
|
||||
expect(input.filename).toBe("photo.jpg");
|
||||
expect(input.inputFormat).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("WorkerInput inputFormat is optional", () => {
|
||||
const input = {
|
||||
toolId: "compress",
|
||||
inputBuffer: Buffer.from("data"),
|
||||
settings: {},
|
||||
filename: "img.png",
|
||||
};
|
||||
|
||||
expect(input.inputFormat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("WorkerOutput has the expected shape", () => {
|
||||
const output = {
|
||||
buffer: Buffer.from("result"),
|
||||
filename: "output.jpg",
|
||||
contentType: "image/jpeg",
|
||||
};
|
||||
|
||||
expect(Buffer.isBuffer(output.buffer)).toBe(true);
|
||||
expect(output.filename).toBe("output.jpg");
|
||||
expect(output.contentType).toBe("image/jpeg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { qpdfAvailable, sofficeAvailable } from "@snapotter/doc-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("doc-engine binary resolution", () => {
|
||||
it("availability checks return booleans without throwing", () => {
|
||||
expect(typeof qpdfAvailable()).toBe("boolean");
|
||||
expect(typeof sofficeAvailable()).toBe("boolean");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { resolveEncoder } from "@snapotter/media-engine";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const ORIGINAL = process.env.SNAPOTTER_HW_ACCEL;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.SNAPOTTER_HW_ACCEL;
|
||||
else process.env.SNAPOTTER_HW_ACCEL = ORIGINAL;
|
||||
});
|
||||
|
||||
describe("resolveEncoder", () => {
|
||||
it("defaults to software encoders", () => {
|
||||
delete process.env.SNAPOTTER_HW_ACCEL;
|
||||
expect(resolveEncoder("h264")).toBe("libx264");
|
||||
expect(resolveEncoder("hevc")).toBe("libx265");
|
||||
expect(resolveEncoder("av1")).toBe("libsvtav1");
|
||||
expect(resolveEncoder("aac")).toBe("aac");
|
||||
});
|
||||
|
||||
it("maps nvenc when SNAPOTTER_HW_ACCEL=nvenc", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "nvenc";
|
||||
expect(resolveEncoder("h264")).toBe("h264_nvenc");
|
||||
expect(resolveEncoder("hevc")).toBe("hevc_nvenc");
|
||||
expect(resolveEncoder("aac")).toBe("aac"); // audio unaffected
|
||||
});
|
||||
|
||||
it("falls back to software for unknown accel values", () => {
|
||||
process.env.SNAPOTTER_HW_ACCEL = "quantum";
|
||||
expect(resolveEncoder("h264")).toBe("libx264");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { parseProgressBlock } from "@snapotter/media-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("parseProgressBlock", () => {
|
||||
it("parses an ffmpeg -progress key=value block", () => {
|
||||
const block = [
|
||||
"frame=120",
|
||||
"fps=24.0",
|
||||
"bitrate= 256.0kbits/s",
|
||||
"total_size=98304",
|
||||
"out_time_us=5000000",
|
||||
"out_time=00:00:05.000000",
|
||||
"speed=1.01x",
|
||||
"progress=continue",
|
||||
].join("\n");
|
||||
const p = parseProgressBlock(block);
|
||||
expect(p.outTimeMs).toBe(5000);
|
||||
expect(p.done).toBe(false);
|
||||
});
|
||||
|
||||
it("flags progress=end", () => {
|
||||
const p = parseProgressBlock("out_time_us=1000000\nprogress=end");
|
||||
expect(p.done).toBe(true);
|
||||
expect(p.outTimeMs).toBe(1000);
|
||||
});
|
||||
|
||||
it("tolerates missing fields", () => {
|
||||
const p = parseProgressBlock("frame=1\nprogress=continue");
|
||||
expect(p.outTimeMs).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { detectModalityFromMime, MODALITIES, MODALITY_POOL, TOOLS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("modality metadata", () => {
|
||||
it("defines five modalities with UI metadata", () => {
|
||||
expect(MODALITIES.map((m) => m.id)).toEqual(["image", "video", "audio", "document", "file"]);
|
||||
for (const m of MODALITIES) {
|
||||
expect(m.name).toBeTruthy();
|
||||
expect(m.icon).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps every modality to a worker pool", () => {
|
||||
expect(MODALITY_POOL.image).toBe("image");
|
||||
expect(MODALITY_POOL.video).toBe("media");
|
||||
expect(MODALITY_POOL.audio).toBe("media");
|
||||
expect(MODALITY_POOL.document).toBe("docs");
|
||||
expect(MODALITY_POOL.file).toBe("docs");
|
||||
});
|
||||
|
||||
it("every tool declares modality, acceptedInputs and executionHint", () => {
|
||||
expect(TOOLS.length).toBeGreaterThanOrEqual(53);
|
||||
for (const tool of TOOLS) {
|
||||
expect(tool.modality).toBe("image"); // phase 3: image only
|
||||
expect(Array.isArray(tool.acceptedInputs)).toBe(true);
|
||||
expect(tool.acceptedInputs.length).toBeGreaterThan(0);
|
||||
expect(["fast", "long"]).toContain(tool.executionHint);
|
||||
}
|
||||
});
|
||||
|
||||
it("AI tools are hinted long (except pure-CV ones)", () => {
|
||||
const ai = TOOLS.filter((t) => t.category === "ai");
|
||||
expect(ai.length).toBeGreaterThan(0);
|
||||
// image-enhancement is categorized "ai" but its core path is pure
|
||||
// sharp/CV; only the optional deepEnhance invokes a model.
|
||||
const pureCvAiTools = new Set(["image-enhancement"]);
|
||||
for (const t of ai) {
|
||||
if (pureCvAiTools.has(t.id)) {
|
||||
expect(t.executionHint).toBe("fast");
|
||||
} else {
|
||||
expect(t.executionHint).toBe("long");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("detects modality from mime", () => {
|
||||
expect(detectModalityFromMime("image/png")).toBe("image");
|
||||
expect(detectModalityFromMime("video/mp4")).toBe("video");
|
||||
expect(detectModalityFromMime("audio/mpeg")).toBe("audio");
|
||||
expect(detectModalityFromMime("application/pdf")).toBe("document");
|
||||
expect(detectModalityFromMime("text/csv")).toBe("file");
|
||||
expect(detectModalityFromMime("")).toBe("file");
|
||||
});
|
||||
});
|
||||
@@ -94,8 +94,11 @@ export default defineConfig({
|
||||
"@/components/navbar": path.resolve(__dirname, "apps/landing/src/components/navbar"),
|
||||
"@": path.resolve(__dirname, "apps/web/src"),
|
||||
"framer-motion": path.join(landingNodeModules, "framer-motion"),
|
||||
"@snapotter/ai": path.resolve(__dirname, "packages/ai/src/index.ts"),
|
||||
"@snapotter/enterprise": path.resolve(__dirname, "packages/enterprise/src/index.ts"),
|
||||
"@snapotter/image-engine": path.resolve(__dirname, "packages/image-engine/src/index.ts"),
|
||||
"@snapotter/media-engine": path.resolve(__dirname, "packages/media-engine/src/index.ts"),
|
||||
"@snapotter/doc-engine": path.resolve(__dirname, "packages/doc-engine/src/index.ts"),
|
||||
"@snapotter/shared/i18n": path.resolve(__dirname, "packages/shared/src/i18n"),
|
||||
"@snapotter/shared": path.resolve(__dirname, "packages/shared/src/index.ts"),
|
||||
fastify: path.join(apiNodeModules, "fastify"),
|
||||
|
||||
Reference in New Issue
Block a user