mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 3a - video depth (22 tools) (#221)
This commit is contained in:
@@ -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": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"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,120 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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
|
||||
"notNull": false,
|
||||
"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 +195,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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458",
|
||||
"prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
|
||||
"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,165 +78,423 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"jobs": {
|
||||
"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": {
|
||||
"name": "jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"input_files": {
|
||||
"name": "input_files",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"output_path": {
|
||||
"name": "output_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"pipelines": {
|
||||
"name": "pipelines",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"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": {},
|
||||
@@ -240,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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,40 @@ export interface MediaRunResult {
|
||||
durationS: number | null;
|
||||
}
|
||||
|
||||
/** Stages every ctx input into scratch (in-<i>-<sanitized>) and returns the paths. */
|
||||
export async function stageMediaInputs(ctx: ToolProcessCtxV2): Promise<string[]> {
|
||||
const dir = join(ctx.scratchDir, "media");
|
||||
await mkdir(dir, { recursive: true });
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < ctx.inputs.length; i++) {
|
||||
const safe = ctx.inputs[i].filename.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
const p = join(dir, `in-${i}-${safe}`);
|
||||
await writeFile(p, ctx.inputs[i].buffer);
|
||||
paths.push(p);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Progress-mapped runFfmpeg (5..95%) against a known duration; 30 min default timeout. */
|
||||
export async function runFfmpegWithProgress(
|
||||
ctx: ToolProcessCtxV2,
|
||||
args: string[],
|
||||
durationS: number | null,
|
||||
opts: { timeoutMs?: number } = {},
|
||||
): Promise<void> {
|
||||
ctx.report(5, "Preparing");
|
||||
await runFfmpeg(args, {
|
||||
signal: ctx.signal,
|
||||
timeoutMs: opts.timeoutMs ?? 30 * 60_000,
|
||||
onProgress: (p) => {
|
||||
if (p.outTimeMs !== null && durationS) {
|
||||
const pct = Math.min(95, 5 + Math.round((p.outTimeMs / (durationS * 1000)) * 90));
|
||||
ctx.report(pct, "Processing");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages the primary input in the scratch dir, probes it, runs ffmpeg with
|
||||
* progress mapped onto ctx.report (5..95%), and returns the output path for
|
||||
@@ -54,16 +88,11 @@ export async function runMediaTool(
|
||||
await writeFile(inPath, ctx.inputs[0].buffer);
|
||||
const info = await probeMedia(inPath);
|
||||
const outPath = join(dir, outName);
|
||||
ctx.report(5, "Preparing");
|
||||
await runFfmpeg(argsFor(inPath, outPath, { durationS: info.durationS }), {
|
||||
signal: ctx.signal,
|
||||
timeoutMs: opts.timeoutMs ?? 30 * 60_000,
|
||||
onProgress: (p) => {
|
||||
if (p.outTimeMs !== null && info.durationS) {
|
||||
const pct = Math.min(95, 5 + Math.round((p.outTimeMs / (info.durationS * 1000)) * 90));
|
||||
ctx.report(pct, "Processing");
|
||||
}
|
||||
},
|
||||
});
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
argsFor(inPath, outPath, { durationS: info.durationS }),
|
||||
info.durationS,
|
||||
opts,
|
||||
);
|
||||
return { outPath, durationS: info.durationS };
|
||||
}
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import { SUBTITLE_INPUTS } from "@snapotter/shared";
|
||||
import { env } from "../config.js";
|
||||
import { type InputHandler, InputValidationError, type PreparedInput } from "./contract.js";
|
||||
|
||||
export type MediaInputKind = "video" | "audio" | "image" | "subtitle";
|
||||
|
||||
const SUBTITLE_EXT_SET = new Set<string>(SUBTITLE_INPUTS);
|
||||
const MAX_SUBTITLE_BYTES = 1 * 1024 * 1024; // 1 MiB
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Video/audio/image/subtitle 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 for video/audio/image kinds. Subtitle kind
|
||||
* skips ffprobe entirely (text-based, extension + content checks only).
|
||||
*/
|
||||
export class MediaInputHandler implements InputHandler {
|
||||
constructor(private kind: "video" | "audio") {}
|
||||
constructor(private kind: MediaInputKind) {}
|
||||
|
||||
async prepare(
|
||||
raw: Buffer,
|
||||
filename: string,
|
||||
opts: { scratchDir: string },
|
||||
): Promise<PreparedInput> {
|
||||
if (this.kind === "subtitle") {
|
||||
return this.prepareSubtitle(raw, filename);
|
||||
}
|
||||
|
||||
const probeDir = join(opts.scratchDir, `probe-${randomUUID()}`);
|
||||
await mkdir(probeDir, { recursive: true });
|
||||
const probePath = join(probeDir, "input");
|
||||
@@ -32,19 +44,28 @@ export class MediaInputHandler implements InputHandler {
|
||||
}
|
||||
const hasVideo = info.streams.some((s) => s.type === "video");
|
||||
const hasAudio = info.streams.some((s) => s.type === "audio");
|
||||
|
||||
// ffprobe reports still images as single-frame video streams in
|
||||
// *_pipe/image2 containers with no duration.
|
||||
const IMAGE_CONTAINER_RE =
|
||||
/(^|,)(png_pipe|image2|bmp_pipe|gif_pipe|jpeg_pipe|tiff_pipe|webp_pipe|svg_pipe)($|,)/;
|
||||
const isStillImage = IMAGE_CONTAINER_RE.test(info.container) && info.durationS === null;
|
||||
|
||||
if (this.kind === "image") {
|
||||
// Image kind: accept still images only; reject audio-only and real videos
|
||||
if (!hasVideo) {
|
||||
throw new InputValidationError("File is not a still image");
|
||||
}
|
||||
if (!isStillImage) {
|
||||
throw new InputValidationError("File is not a still image");
|
||||
}
|
||||
return { buffer: raw, filename };
|
||||
}
|
||||
|
||||
if (this.kind === "video" && !hasVideo) {
|
||||
throw new InputValidationError("File contains no video stream");
|
||||
}
|
||||
// ffprobe reports still images as single-frame video streams in
|
||||
// *_pipe/image2 containers with no duration; a video tool must
|
||||
// reject those (carry-forward from phase-3 input-handler review).
|
||||
const IMAGE_CONTAINER_RE =
|
||||
/(^|,)(png_pipe|image2|bmp_pipe|gif_pipe|jpeg_pipe|tiff_pipe|webp_pipe|svg_pipe)($|,)/;
|
||||
if (
|
||||
this.kind === "video" &&
|
||||
IMAGE_CONTAINER_RE.test(info.container) &&
|
||||
info.durationS === null
|
||||
) {
|
||||
if (this.kind === "video" && isStillImage) {
|
||||
throw new InputValidationError("File is a still image, not a video");
|
||||
}
|
||||
if (this.kind === "audio" && !hasAudio) {
|
||||
@@ -72,4 +93,20 @@ export class MediaInputHandler implements InputHandler {
|
||||
await rm(probeDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private prepareSubtitle(raw: Buffer, filename: string): PreparedInput {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (!SUBTITLE_EXT_SET.has(ext)) {
|
||||
throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)");
|
||||
}
|
||||
if (raw.length > MAX_SUBTITLE_BYTES) {
|
||||
throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)");
|
||||
}
|
||||
const text = new TextDecoder("utf-8", { fatal: false }).decode(raw);
|
||||
const looksLikeSubtitle = /-->/.test(text) || /\[Script Info\]/i.test(text);
|
||||
if (!looksLikeSubtitle) {
|
||||
throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)");
|
||||
}
|
||||
return { buffer: raw, filename };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js";
|
||||
import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js";
|
||||
import { InputValidationError } from "../modality/contract.js";
|
||||
import { inputHandlerFor } from "../modality/input-handler.js";
|
||||
import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js";
|
||||
import { getAuthUser } from "../plugins/auth.js";
|
||||
import { updateSingleFileProgress } from "./progress.js";
|
||||
|
||||
@@ -69,6 +70,12 @@ export interface ToolRouteConfig<T> {
|
||||
* inputRefs in arrival order.
|
||||
*/
|
||||
maxInputs?: number;
|
||||
/**
|
||||
* Per-position input kind overrides for mixed-input tools (e.g. video +
|
||||
* subtitle). Input i validates with kind inputKinds[Math.min(i, len-1)].
|
||||
* When absent, the tool's modality drives a single handler as before.
|
||||
*/
|
||||
inputKinds?: ("video" | "audio" | "image" | "subtitle")[];
|
||||
/** Zod schema that validates the settings JSON from the request. */
|
||||
settingsSchema: z.ZodType<T, z.ZodTypeDef, unknown>;
|
||||
/** The processing function: takes input buffer + validated settings, returns output. */
|
||||
@@ -100,6 +107,7 @@ export interface ToolRouteConfig<T> {
|
||||
export interface AnyToolRouteConfig {
|
||||
toolId: string;
|
||||
maxInputs?: number;
|
||||
inputKinds?: ("video" | "audio" | "image" | "subtitle")[];
|
||||
settingsSchema: z.ZodType<unknown, z.ZodTypeDef, unknown>;
|
||||
process: (
|
||||
inputBuffer: Buffer,
|
||||
@@ -294,6 +302,23 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-position input handlers: when inputKinds is present,
|
||||
// each position gets a MediaInputHandler for its kind; otherwise
|
||||
// the tool's modality drives a single shared handler as before.
|
||||
const kindHandlers: Map<MediaInputKind, MediaInputHandler> = new Map();
|
||||
function handlerForPosition(idx: number) {
|
||||
if (config.inputKinds) {
|
||||
const kind = config.inputKinds[Math.min(idx, config.inputKinds.length - 1)];
|
||||
let h = kindHandlers.get(kind);
|
||||
if (!h) {
|
||||
h = new MediaInputHandler(kind);
|
||||
kindHandlers.set(kind, h);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
return inputHandlerFor(modality);
|
||||
}
|
||||
|
||||
// Prepare all files through the modality input handler
|
||||
const inputRefs: string[] = [];
|
||||
for (let i = 0; i < received.length; i++) {
|
||||
@@ -303,7 +328,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
let fname = upload.filename;
|
||||
|
||||
try {
|
||||
const prepared = await inputHandlerFor(modality).prepare(fileBuffer, fname, {
|
||||
const prepared = await handlerForPosition(i).prepare(fileBuffer, fname, {
|
||||
scratchDir,
|
||||
lenient: config.skipStructuralValidation,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const TARGETS = {
|
||||
"16:9": [16, 9],
|
||||
"9:16": [9, 16],
|
||||
"1:1": [1, 1],
|
||||
"4:3": [4, 3],
|
||||
"3:4": [3, 4],
|
||||
} as const;
|
||||
type TargetKey = keyof typeof TARGETS;
|
||||
|
||||
/** Output canvas for a source WxH fitted into ratio rw:rh (even dims). */
|
||||
export function canvasFor(
|
||||
w: number,
|
||||
h: number,
|
||||
rw: number,
|
||||
rh: number,
|
||||
): { cw: number; ch: number } {
|
||||
let cw: number;
|
||||
let ch: number;
|
||||
if (w * rh >= h * rw) {
|
||||
cw = w;
|
||||
ch = Math.round((w * rh) / rw);
|
||||
} else {
|
||||
ch = h;
|
||||
cw = Math.round((h * rw) / rh);
|
||||
}
|
||||
cw += cw % 2;
|
||||
ch += ch % 2;
|
||||
return { cw, ch };
|
||||
}
|
||||
|
||||
const settingsSchema = z.object({
|
||||
target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("9:16"),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#000000"),
|
||||
});
|
||||
|
||||
export function registerAspectPad(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "aspect-pad",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("aspect-pad is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_padded${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
const srcW = v?.width ?? 0;
|
||||
const srcH = v?.height ?? 0;
|
||||
|
||||
const [rw, rh] = TARGETS[settings.target as TargetKey];
|
||||
const { cw, ch } = canvasFor(srcW, srcH, rw, rh);
|
||||
|
||||
const c = settings.color.replace("#", "0x");
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`pad=${cw}:${ch}:(ow-iw)/2:(oh-ih)/2:color=${c}`,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const TARGETS = {
|
||||
"16:9": [16, 9],
|
||||
"9:16": [9, 16],
|
||||
"1:1": [1, 1],
|
||||
"4:3": [4, 3],
|
||||
"3:4": [3, 4],
|
||||
} as const;
|
||||
type TargetKey = keyof typeof TARGETS;
|
||||
|
||||
/** Output canvas for a source WxH fitted into ratio rw:rh (even dims). */
|
||||
export function canvasFor(
|
||||
w: number,
|
||||
h: number,
|
||||
rw: number,
|
||||
rh: number,
|
||||
): { cw: number; ch: number } {
|
||||
let cw: number;
|
||||
let ch: number;
|
||||
if (w * rh >= h * rw) {
|
||||
cw = w;
|
||||
ch = Math.round((w * rh) / rw);
|
||||
} else {
|
||||
ch = h;
|
||||
cw = Math.round((h * rw) / rh);
|
||||
}
|
||||
cw += cw % 2;
|
||||
ch += ch % 2;
|
||||
return { cw, ch };
|
||||
}
|
||||
|
||||
const settingsSchema = z.object({
|
||||
target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("16:9"),
|
||||
blur: z.number().min(2).max(50).default(20),
|
||||
});
|
||||
|
||||
export function registerBlurPad(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "blur-pad",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("blur-pad is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_blurpad${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
const srcW = v?.width ?? 0;
|
||||
const srcH = v?.height ?? 0;
|
||||
|
||||
const [rw, rh] = TARGETS[settings.target as TargetKey];
|
||||
const { cw, ch } = canvasFor(srcW, srcH, rw, rh);
|
||||
|
||||
const filter = `[0:v]split[bg][fg];[bg]scale=${cw}:${ch}:force_original_aspect_ratio=increase,crop=${cw}:${ch},gblur=sigma=${settings.blur}[b];[b][fg]overlay=(W-w)/2:(H-h)/2[v]`;
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
"-i",
|
||||
inPath,
|
||||
"-filter_complex",
|
||||
filter,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder, resolveFontFile } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
fontSize: z.number().int().min(8).max(72).default(24),
|
||||
});
|
||||
|
||||
export function registerBurnSubtitles(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "burn-subtitles",
|
||||
maxInputs: 2,
|
||||
inputKinds: ["video", "subtitle"],
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("burn-subtitles is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length !== 2) {
|
||||
throw new InputValidationError("Provide a video file and a subtitle file");
|
||||
}
|
||||
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_subtitled.mp4`;
|
||||
|
||||
const font = resolveFontFile();
|
||||
if (!font) {
|
||||
throw new Error("No usable font found for subtitles (set SNAPOTTER_FONT_FILE)");
|
||||
}
|
||||
|
||||
const paths = await stageMediaInputs(ctx);
|
||||
const videoPath = paths[0];
|
||||
const subPath = paths[1];
|
||||
const info = await probeMedia(videoPath);
|
||||
|
||||
const vf = `subtitles=${subPath}:fontsdir=${dirname(font.file)}:force_style='FontName=${font.family},FontSize=${settings.fontSize}'`;
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
"-i",
|
||||
videoPath,
|
||||
"-vf",
|
||||
vf,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: outName,
|
||||
contentType: "video/mp4",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { extname } from "node:path";
|
||||
import { resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runMediaTool, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
fps: z.number().min(1).max(120).default(30),
|
||||
});
|
||||
|
||||
export function registerChangeFps(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "change-fps",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("change-fps is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_fps${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`fps=${settings.fps}`,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
out,
|
||||
]);
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
width: z.number().int().min(16),
|
||||
height: z.number().int().min(16),
|
||||
x: z.number().int().min(0).default(0),
|
||||
y: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export function registerCropVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "crop-video",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("crop-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_cropped${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
const W = v?.width ?? 0;
|
||||
const H = v?.height ?? 0;
|
||||
|
||||
if (settings.x + settings.width > W || settings.y + settings.height > H) {
|
||||
throw new InputValidationError(
|
||||
`Crop rectangle ${settings.width}x${settings.height}+${settings.x}+${settings.y} exceeds video size ${W}x${H}`,
|
||||
);
|
||||
}
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`crop=${settings.width}:${settings.height}:${settings.x}:${settings.y}`,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
language: z
|
||||
.string()
|
||||
.regex(/^[a-z]{3}$/)
|
||||
.default("eng"),
|
||||
});
|
||||
|
||||
export function registerEmbedSubtitles(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "embed-subtitles",
|
||||
maxInputs: 2,
|
||||
inputKinds: ["video", "subtitle"],
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("embed-subtitles is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length !== 2) {
|
||||
throw new InputValidationError("Provide a video file and a subtitle file");
|
||||
}
|
||||
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = basename(ctx.inputs[0].filename, extname(ctx.inputs[0].filename));
|
||||
|
||||
const srcExt = extname(ctx.inputs[0].filename).toLowerCase();
|
||||
const toMp4 = [".mp4", ".mov", ".m4v"].includes(srcExt);
|
||||
const outExt = toMp4 ? ".mp4" : ".mkv";
|
||||
const scodec = toMp4 ? "mov_text" : "srt";
|
||||
|
||||
const outName = `${base}_subs${outExt}`;
|
||||
const contentType = videoContentType(outExt);
|
||||
|
||||
const paths = await stageMediaInputs(ctx);
|
||||
const videoPath = paths[0];
|
||||
const subPath = paths[1];
|
||||
const info = await probeMedia(videoPath);
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
"-i",
|
||||
videoPath,
|
||||
"-i",
|
||||
subPath,
|
||||
"-map",
|
||||
"0",
|
||||
"-map",
|
||||
"1:0",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
scodec,
|
||||
"-metadata:s:s:0",
|
||||
`language=${settings.language}`,
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: outName,
|
||||
contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerExtractSubtitles(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "extract-subtitles",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("extract-subtitles is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}.srt`;
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
|
||||
try {
|
||||
await runFfmpegWithProgress(ctx, ["-i", inPath, "-map", "0:s:0", outPath], info.durationS);
|
||||
} catch (err) {
|
||||
// A canceled job must not masquerade as a missing subtitle track
|
||||
if (ctx.signal.aborted) throw err;
|
||||
throw new InputValidationError("No subtitle track found in this video");
|
||||
}
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: outName,
|
||||
contentType: "application/x-subrip",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["mp4", "webm"]).default("mp4"),
|
||||
});
|
||||
|
||||
export function registerGifToVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "gif-to-video",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("gif-to-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}.${settings.format}`;
|
||||
const contentType = settings.format === "mp4" ? "video/mp4" : "video/webm";
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
|
||||
if (settings.format === "webm") {
|
||||
return [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||||
"-c:v",
|
||||
resolveEncoder("vp9"),
|
||||
"-b:v",
|
||||
"0",
|
||||
"-crf",
|
||||
"32",
|
||||
"-an",
|
||||
out,
|
||||
];
|
||||
}
|
||||
return [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-an",
|
||||
out,
|
||||
];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { join } from "node:path";
|
||||
import { resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const DIMS: Record<string, { w: number; h: number }> = {
|
||||
"1080p": { w: 1920, h: 1080 },
|
||||
"720p": { w: 1280, h: 720 },
|
||||
square: { w: 1080, h: 1080 },
|
||||
};
|
||||
|
||||
const settingsSchema = z.object({
|
||||
secondsPerImage: z.number().min(0.5).max(10).default(2),
|
||||
resolution: z.enum(["1080p", "720p", "square"]).default("720p"),
|
||||
fps: z.number().int().min(10).max(60).default(30),
|
||||
});
|
||||
|
||||
export function registerImagesToVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "images-to-video",
|
||||
maxInputs: 60,
|
||||
inputKinds: ["image"],
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("images-to-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length < 2) {
|
||||
throw new InputValidationError("Provide at least two images");
|
||||
}
|
||||
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const { w: W, h: H } = DIMS[settings.resolution];
|
||||
|
||||
const paths = await stageMediaInputs(ctx);
|
||||
|
||||
const parts: string[] = [];
|
||||
const refs: string[] = [];
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
parts.push(
|
||||
`[${i}:v]scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1[v${i}]`,
|
||||
);
|
||||
refs.push(`[v${i}]`);
|
||||
}
|
||||
const filter = `${parts.join(";")};${refs.join("")}concat=n=${paths.length}:v=1:a=0[v]`;
|
||||
|
||||
const args = [
|
||||
...paths.flatMap((p) => ["-loop", "1", "-t", String(settings.secondsPerImage), "-i", p]),
|
||||
"-filter_complex",
|
||||
filter,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-r",
|
||||
String(settings.fps),
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
join(ctx.scratchDir, "media", "slideshow.mp4"),
|
||||
];
|
||||
|
||||
const progressDuration = paths.length * settings.secondsPerImage;
|
||||
await runFfmpegWithProgress(ctx, args, progressDuration);
|
||||
|
||||
return {
|
||||
scratchPath: join(ctx.scratchDir, "media", "slideshow.mp4"),
|
||||
filename: "slideshow.mp4",
|
||||
contentType: "video/mp4",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,12 +4,16 @@ import type { FastifyInstance } from "fastify";
|
||||
import { db, schema } from "../../db/index.js";
|
||||
import { registerColorAdjustments } from "./adjust-colors.js";
|
||||
import { registerAiCanvasExpand } from "./ai-canvas-expand.js";
|
||||
import { registerAspectPad } from "./aspect-pad.js";
|
||||
import { registerBarcodeRead } from "./barcode-read.js";
|
||||
import { registerBeautify } from "./beautify.js";
|
||||
import { registerBlurFaces } from "./blur-faces.js";
|
||||
import { registerBlurPad } from "./blur-pad.js";
|
||||
import { registerBookletPdf } from "./booklet-pdf.js";
|
||||
import { registerBorder } from "./border.js";
|
||||
import { registerBulkRename } from "./bulk-rename.js";
|
||||
import { registerBurnSubtitles } from "./burn-subtitles.js";
|
||||
import { registerChangeFps } from "./change-fps.js";
|
||||
import { registerCollage } from "./collage.js";
|
||||
import { registerColorBlindness } from "./color-blindness.js";
|
||||
import { registerColorPalette } from "./color-palette.js";
|
||||
@@ -25,16 +29,20 @@ import { registerConvertAudio } from "./convert-audio.js";
|
||||
import { registerConvertVideo } from "./convert-video.js";
|
||||
import { registerCrop } from "./crop.js";
|
||||
import { registerCropPdf } from "./crop-pdf.js";
|
||||
import { registerCropVideo } from "./crop-video.js";
|
||||
import { registerCsvExcel } from "./csv-excel.js";
|
||||
import { registerCsvJson } from "./csv-json.js";
|
||||
import { registerEditMetadata } from "./edit-metadata.js";
|
||||
import { registerEmbedSubtitles } from "./embed-subtitles.js";
|
||||
import { registerEnhanceFaces } from "./enhance-faces.js";
|
||||
import { registerEraseObject } from "./erase-object.js";
|
||||
import { registerExtractAudio } from "./extract-audio.js";
|
||||
import { registerExtractPages } from "./extract-pages.js";
|
||||
import { registerExtractSubtitles } from "./extract-subtitles.js";
|
||||
import { registerFavicon } from "./favicon.js";
|
||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
import { registerFlattenPdf } from "./flatten-pdf.js";
|
||||
import { registerGifToVideo } from "./gif-to-video.js";
|
||||
import { registerGifTools } from "./gif-tools.js";
|
||||
import { registerGrayscalePdf } from "./grayscale-pdf.js";
|
||||
import { registerHtmlToImage } from "./html-to-image.js";
|
||||
@@ -42,12 +50,14 @@ import { registerHtmlToPdf } from "./html-to-pdf.js";
|
||||
import { registerImageEnhancement } from "./image-enhancement.js";
|
||||
import { registerImageToBase64 } from "./image-to-base64.js";
|
||||
import { registerImageToPdf } from "./image-to-pdf.js";
|
||||
import { registerImagesToVideo } from "./images-to-video.js";
|
||||
import { registerInfo } from "./info.js";
|
||||
import { registerJsonXml } from "./json-xml.js";
|
||||
import { registerLinearizePdf } from "./linearize-pdf.js";
|
||||
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
|
||||
import { registerMemeGenerator } from "./meme-generator.js";
|
||||
import { registerMergePdf } from "./merge-pdf.js";
|
||||
import { registerMergeVideos } from "./merge-videos.js";
|
||||
import { registerMuteVideo } from "./mute-video.js";
|
||||
import { registerNoiseRemoval } from "./noise-removal.js";
|
||||
import { registerNupPdf } from "./nup-pdf.js";
|
||||
@@ -68,16 +78,21 @@ import { registerRedactPdf } from "./redact-pdf.js";
|
||||
import { registerRemoveBackground } from "./remove-background.js";
|
||||
import { registerRemovePages } from "./remove-pages.js";
|
||||
import { registerRepairPdf } from "./repair-pdf.js";
|
||||
import { registerReplaceAudio } from "./replace-audio.js";
|
||||
import { registerReplaceColor } from "./replace-color.js";
|
||||
import { registerResize } from "./resize.js";
|
||||
import { registerResizeVideo } from "./resize-video.js";
|
||||
import { registerRestorePhoto } from "./restore-photo.js";
|
||||
import { registerReverseVideo } from "./reverse-video.js";
|
||||
import { registerRotate } from "./rotate.js";
|
||||
import { registerRotatePdf } from "./rotate-pdf.js";
|
||||
import { registerRotateVideo } from "./rotate-video.js";
|
||||
import { registerSharpening } from "./sharpening.js";
|
||||
import { registerSmartCrop } from "./smart-crop.js";
|
||||
import { registerSplit } from "./split.js";
|
||||
import { registerSplitCsv } from "./split-csv.js";
|
||||
import { registerSplitPdf } from "./split-pdf.js";
|
||||
import { registerStabilizeVideo } from "./stabilize-video.js";
|
||||
import { registerStitch } from "./stitch.js";
|
||||
import { registerStripMetadata } from "./strip-metadata.js";
|
||||
import { registerSvgToRaster } from "./svg-to-raster.js";
|
||||
@@ -88,10 +103,17 @@ import { registerTrimVideo } from "./trim-video.js";
|
||||
import { registerUnlockPdf } from "./unlock-pdf.js";
|
||||
import { registerUpscale } from "./upscale.js";
|
||||
import { registerVectorize } from "./vectorize.js";
|
||||
import { registerVideoColor } from "./video-color.js";
|
||||
import { registerVideoLoudnorm } from "./video-loudnorm.js";
|
||||
import { registerVideoMetadata } from "./video-metadata.js";
|
||||
import { registerVideoSpeed } from "./video-speed.js";
|
||||
import { registerVideoToFrames } from "./video-to-frames.js";
|
||||
import { registerVideoToGif } from "./video-to-gif.js";
|
||||
import { registerVideoToWebp } from "./video-to-webp.js";
|
||||
import { registerWatermarkImage } from "./watermark-image.js";
|
||||
import { registerWatermarkPdf } from "./watermark-pdf.js";
|
||||
import { registerWatermarkText } from "./watermark-text.js";
|
||||
import { registerWatermarkVideo } from "./watermark-video.js";
|
||||
import { registerWordToPdf } from "./word-to-pdf.js";
|
||||
|
||||
/**
|
||||
@@ -178,11 +200,33 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "color-blindness", register: registerColorBlindness },
|
||||
|
||||
// Video
|
||||
{ id: "convert-video", register: registerConvertVideo },
|
||||
{ id: "aspect-pad", register: registerAspectPad },
|
||||
{ id: "blur-pad", register: registerBlurPad },
|
||||
{ id: "burn-subtitles", register: registerBurnSubtitles },
|
||||
{ id: "change-fps", register: registerChangeFps },
|
||||
{ id: "compress-video", register: registerCompressVideo },
|
||||
{ id: "trim-video", register: registerTrimVideo },
|
||||
{ id: "convert-video", register: registerConvertVideo },
|
||||
{ id: "crop-video", register: registerCropVideo },
|
||||
{ id: "embed-subtitles", register: registerEmbedSubtitles },
|
||||
{ id: "extract-subtitles", register: registerExtractSubtitles },
|
||||
{ id: "gif-to-video", register: registerGifToVideo },
|
||||
{ id: "images-to-video", register: registerImagesToVideo },
|
||||
{ id: "merge-videos", register: registerMergeVideos },
|
||||
{ id: "mute-video", register: registerMuteVideo },
|
||||
{ id: "replace-audio", register: registerReplaceAudio },
|
||||
{ id: "resize-video", register: registerResizeVideo },
|
||||
{ id: "reverse-video", register: registerReverseVideo },
|
||||
{ id: "rotate-video", register: registerRotateVideo },
|
||||
{ id: "stabilize-video", register: registerStabilizeVideo },
|
||||
{ id: "trim-video", register: registerTrimVideo },
|
||||
{ id: "video-color", register: registerVideoColor },
|
||||
{ id: "video-loudnorm", register: registerVideoLoudnorm },
|
||||
{ id: "video-metadata", register: registerVideoMetadata },
|
||||
{ id: "video-speed", register: registerVideoSpeed },
|
||||
{ id: "video-to-frames", register: registerVideoToFrames },
|
||||
{ id: "video-to-gif", register: registerVideoToGif },
|
||||
{ id: "video-to-webp", register: registerVideoToWebp },
|
||||
{ id: "watermark-video", register: registerWatermarkVideo },
|
||||
|
||||
// Audio
|
||||
{ id: "convert-audio", register: registerConvertAudio },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerMergeVideos(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "merge-videos",
|
||||
maxInputs: 10,
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("merge-videos is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length < 2) {
|
||||
throw new InputValidationError("Merging needs at least two videos");
|
||||
}
|
||||
|
||||
const paths = await stageMediaInputs(ctx);
|
||||
const probes = [];
|
||||
for (const p of paths) {
|
||||
probes.push(await probeMedia(p));
|
||||
}
|
||||
|
||||
const first = probes[0].streams.find((s) => s.type === "video");
|
||||
if (!first?.width || !first?.height) {
|
||||
throw new InputValidationError("First input has no video stream");
|
||||
}
|
||||
|
||||
// Canvas: first video's dims, rounded to even
|
||||
const W = first.width - (first.width % 2);
|
||||
const H = first.height - (first.height % 2);
|
||||
const totalS = probes.reduce((a, p) => a + (p.durationS ?? 0), 0) || null;
|
||||
|
||||
// Build per-input normalize chains + concat
|
||||
const parts: string[] = [];
|
||||
const refs: string[] = [];
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
parts.push(
|
||||
`[${i}:v]scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v${i}]`,
|
||||
);
|
||||
const hasAudio = probes[i].streams.some((s) => s.type === "audio");
|
||||
if (hasAudio) {
|
||||
parts.push(`[${i}:a]aresample=48000,aformat=channel_layouts=stereo[a${i}]`);
|
||||
} else {
|
||||
const d = probes[i].durationS ?? 1;
|
||||
parts.push(`anullsrc=r=48000:cl=stereo,atrim=duration=${d}[a${i}]`);
|
||||
}
|
||||
refs.push(`[v${i}][a${i}]`);
|
||||
}
|
||||
|
||||
const filter = `${parts.join(";")};${refs.join("")}concat=n=${paths.length}:v=1:a=1[v][a]`;
|
||||
const outPath = join(ctx.scratchDir, "media", "merged.mp4");
|
||||
|
||||
const args = [
|
||||
...paths.flatMap((p) => ["-i", p]),
|
||||
"-filter_complex",
|
||||
filter,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
resolveEncoder("aac"),
|
||||
outPath,
|
||||
];
|
||||
|
||||
await runFfmpegWithProgress(ctx, args, totalS);
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: "merged.mp4",
|
||||
contentType: "video/mp4",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerReplaceAudio(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "replace-audio",
|
||||
maxInputs: 2,
|
||||
inputKinds: ["video", "audio"],
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("replace-audio is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length !== 2) {
|
||||
throw new InputValidationError("Provide a video and an audio file");
|
||||
}
|
||||
|
||||
const paths = await stageMediaInputs(ctx);
|
||||
const videoProbe = await probeMedia(paths[0]);
|
||||
const audioProbe = await probeMedia(paths[1]);
|
||||
|
||||
const hasVideo = videoProbe.streams.some((s) => s.type === "video");
|
||||
if (!hasVideo) {
|
||||
throw new InputValidationError("First file must be a video");
|
||||
}
|
||||
|
||||
const hasAudio = audioProbe.streams.some((s) => s.type === "audio");
|
||||
if (!hasAudio) {
|
||||
throw new InputValidationError("Second file must be an audio file");
|
||||
}
|
||||
|
||||
// vp8/vp9 in webm cannot be muxed into mp4; keep the source container
|
||||
const vcodec = videoProbe.streams.find((s) => s.type === "video")?.codec ?? "";
|
||||
const ext = ["vp8", "vp9"].includes(vcodec) ? ".webm" : ".mp4";
|
||||
const videoBase = basename(ctx.inputs[0].filename, extname(ctx.inputs[0].filename));
|
||||
const outName = `${videoBase}_newaudio${ext}`;
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
|
||||
const args = [
|
||||
"-i",
|
||||
paths[0],
|
||||
"-i",
|
||||
paths[1],
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
resolveEncoder("aac"),
|
||||
"-shortest",
|
||||
outPath,
|
||||
];
|
||||
|
||||
const totalS = videoProbe.durationS;
|
||||
await runFfmpegWithProgress(ctx, args, totalS);
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: outName,
|
||||
contentType: videoContentType(ext),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { extname } from "node:path";
|
||||
import { resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runMediaTool, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const PRESET_HEIGHTS: Record<string, number> = {
|
||||
"2160p": 2160,
|
||||
"1440p": 1440,
|
||||
"1080p": 1080,
|
||||
"720p": 720,
|
||||
"480p": 480,
|
||||
"360p": 360,
|
||||
};
|
||||
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
width: z.number().int().min(16).max(7680).optional(),
|
||||
height: z.number().int().min(16).max(4320).optional(),
|
||||
preset: z.enum(["custom", "2160p", "1440p", "1080p", "720p", "480p", "360p"]).default("custom"),
|
||||
})
|
||||
.refine((s) => s.preset !== "custom" || s.width !== undefined || s.height !== undefined, {
|
||||
message: "Set a width, height, or preset",
|
||||
});
|
||||
|
||||
export function registerResizeVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "resize-video",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("resize-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_resized${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
let w: number | string;
|
||||
let h: number | string;
|
||||
if (settings.preset !== "custom") {
|
||||
w = -2;
|
||||
h = PRESET_HEIGHTS[settings.preset];
|
||||
} else {
|
||||
w = settings.width ?? -2;
|
||||
h = settings.height ?? -2;
|
||||
}
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`scale=${w}:${h}:flags=lanczos`,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
out,
|
||||
]);
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerReverseVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "reverse-video",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("reverse-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_reversed${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
|
||||
if ((info.durationS ?? 0) > 300) {
|
||||
throw new InputValidationError("Reverse is limited to clips up to 5 minutes");
|
||||
}
|
||||
|
||||
const hasAudio = info.streams.some((s) => s.type === "audio");
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
|
||||
let args: string[];
|
||||
|
||||
if (hasAudio) {
|
||||
args = [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
"reverse",
|
||||
"-af",
|
||||
"areverse",
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
resolveEncoder("aac"),
|
||||
outPath,
|
||||
];
|
||||
} else {
|
||||
args = [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
"reverse",
|
||||
"-an",
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
outPath,
|
||||
];
|
||||
}
|
||||
|
||||
await runFfmpegWithProgress(ctx, args, info.durationS);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { extname } from "node:path";
|
||||
import { resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runMediaTool, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const VF_MAP: Record<string, string> = {
|
||||
cw90: "transpose=1",
|
||||
ccw90: "transpose=2",
|
||||
"180": "transpose=1,transpose=1",
|
||||
hflip: "hflip",
|
||||
vflip: "vflip",
|
||||
};
|
||||
|
||||
const settingsSchema = z.object({
|
||||
transform: z.enum(["cw90", "ccw90", "180", "hflip", "vflip"]),
|
||||
});
|
||||
|
||||
export function registerRotateVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "rotate-video",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("rotate-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_rotated${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
VF_MAP[settings.transform],
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
out,
|
||||
]);
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
smoothing: z.number().int().min(5).max(60).default(15),
|
||||
});
|
||||
|
||||
export function registerStabilizeVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "stabilize-video",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("stabilize-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_stabilized${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const trf = join(ctx.scratchDir, "media", "stab.trf");
|
||||
const nullOut = join(ctx.scratchDir, "media", "null.out");
|
||||
|
||||
// Pass 1: motion analysis (no progress mapping; stdout carries -progress pipe:1)
|
||||
ctx.report(5, "Analyzing");
|
||||
await runFfmpeg(
|
||||
["-i", inPath, "-an", "-vf", `vidstabdetect=result=${trf}`, "-f", "null", nullOut],
|
||||
{
|
||||
signal: ctx.signal,
|
||||
timeoutMs: 30 * 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Pass 2: stabilization with re-encode
|
||||
ctx.report(50, "Stabilizing");
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`vidstabtransform=input=${trf}:smoothing=${settings.smoothing}`,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { extname } from "node:path";
|
||||
import { resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runMediaTool, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
brightness: z.number().min(-1).max(1).default(0),
|
||||
contrast: z.number().min(0).max(4).default(1),
|
||||
saturation: z.number().min(0).max(3).default(1),
|
||||
gamma: z.number().min(0.1).max(10).default(1),
|
||||
});
|
||||
|
||||
export function registerVideoColor(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "video-color",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("video-color is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_color${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`eq=brightness=${settings.brightness}:contrast=${settings.contrast}:saturation=${settings.saturation}:gamma=${settings.gamma}`,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
out,
|
||||
]);
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerVideoLoudnorm(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "video-loudnorm",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("video-loudnorm is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_loudnorm${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
|
||||
if (!info.streams.some((s) => s.type === "audio")) {
|
||||
throw new InputValidationError("This video has no audio track to normalize");
|
||||
}
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
|
||||
const args = [
|
||||
"-i",
|
||||
inPath,
|
||||
"-af",
|
||||
"loudnorm=I=-16:TP=-1.5:LRA=11",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
resolveEncoder("aac"),
|
||||
"-b:a",
|
||||
"192k",
|
||||
outPath,
|
||||
];
|
||||
|
||||
await runFfmpegWithProgress(ctx, args, info.durationS);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerVideoMetadata(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "video-metadata",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("video-metadata is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_clean${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
["-i", inPath, "-map", "0", "-map_metadata", "-1", "-c", "copy", outPath],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: outName,
|
||||
contentType,
|
||||
resultPayload: {
|
||||
metadata: {
|
||||
container: info.container,
|
||||
durationS: info.durationS,
|
||||
bitrateKbps: info.bitrateKbps,
|
||||
streams: info.streams,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia, resolveEncoder } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
factor: z.number().min(0.25).max(4).default(2),
|
||||
keepPitch: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/** atempo only accepts 0.5..100; chain factors of 0.5 until in range. */
|
||||
export function buildAtempoChain(factor: number): string {
|
||||
const parts: string[] = [];
|
||||
let f = factor;
|
||||
while (f < 0.5) {
|
||||
parts.push("atempo=0.5");
|
||||
f /= 0.5;
|
||||
}
|
||||
parts.push(`atempo=${f}`);
|
||||
return parts.join(",");
|
||||
}
|
||||
|
||||
export function registerVideoSpeed(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "video-speed",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("video-speed is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_speed${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const hasAudio = info.streams.some((s) => s.type === "audio");
|
||||
|
||||
let args: string[];
|
||||
|
||||
if (hasAudio) {
|
||||
const audioStream = info.streams.find((s) => s.type === "audio");
|
||||
const sr = audioStream?.sampleRate ?? 48000;
|
||||
let audioChain: string;
|
||||
if (settings.keepPitch) {
|
||||
audioChain = buildAtempoChain(settings.factor);
|
||||
} else {
|
||||
audioChain = `asetrate=${sr}*${settings.factor},aresample=${sr}`;
|
||||
}
|
||||
|
||||
args = [
|
||||
"-i",
|
||||
inPath,
|
||||
"-filter_complex",
|
||||
`[0:v]setpts=PTS/${settings.factor}[v];[0:a]${audioChain}[a]`,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
resolveEncoder("aac"),
|
||||
];
|
||||
} else {
|
||||
args = [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`setpts=PTS/${settings.factor}`,
|
||||
"-an",
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
];
|
||||
}
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
args.push(outPath);
|
||||
|
||||
await runFfmpegWithProgress(ctx, args, info.durationS);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { probeMedia, runFfmpeg } from "@snapotter/media-engine";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { stageMediaInputs } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
mode: z.enum(["all", "nth", "timestamps"]).default("all"),
|
||||
n: z.number().int().min(2).max(1000).default(10),
|
||||
timestamps: z.string().max(500).default(""),
|
||||
format: z.enum(["png", "jpg"]).default("png"),
|
||||
})
|
||||
.refine((s) => s.mode !== "timestamps" || s.timestamps.trim().length > 0, {
|
||||
message: "Provide timestamps",
|
||||
});
|
||||
|
||||
export function registerVideoToFrames(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "video-to-frames",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("video-to-frames is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const durationS = info.durationS ?? 0;
|
||||
|
||||
const framesDir = join(ctx.scratchDir, "media", "frames");
|
||||
await mkdir(framesDir, { recursive: true });
|
||||
|
||||
if (settings.mode === "all") {
|
||||
// Guard: estimate frames as durationS * 30 (safe ceiling; probe lacks fps)
|
||||
if (durationS * 30 > 2000) {
|
||||
throw new InputValidationError("Too many frames; use every-Nth or timestamps mode");
|
||||
}
|
||||
ctx.report(5, "Extracting frames");
|
||||
await runFfmpeg(["-i", inPath, join(framesDir, `frame-%06d.${settings.format}`)], {
|
||||
signal: ctx.signal,
|
||||
timeoutMs: 30 * 60_000,
|
||||
});
|
||||
} else if (settings.mode === "nth") {
|
||||
ctx.report(5, "Extracting frames");
|
||||
await runFfmpeg(
|
||||
[
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`select=not(mod(n\\,${settings.n}))`,
|
||||
"-fps_mode",
|
||||
"vfr",
|
||||
join(framesDir, `frame-%06d.${settings.format}`),
|
||||
],
|
||||
{ signal: ctx.signal, timeoutMs: 30 * 60_000 },
|
||||
);
|
||||
} else {
|
||||
// timestamps mode
|
||||
const stamps = settings.timestamps.split(",").map((t) => Number(t.trim()));
|
||||
for (const t of stamps) {
|
||||
if (Number.isNaN(t) || t < 0) {
|
||||
throw new InputValidationError(
|
||||
`Invalid timestamp "${t}": each timestamp must be a non-negative number`,
|
||||
);
|
||||
}
|
||||
if (t > durationS) {
|
||||
throw new InputValidationError(
|
||||
`Timestamp ${t}s is beyond the video duration of ${durationS.toFixed(1)}s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (stamps.length > 50) {
|
||||
throw new InputValidationError("Too many timestamps; maximum is 50 per extraction");
|
||||
}
|
||||
for (let idx = 0; idx < stamps.length; idx++) {
|
||||
const t = stamps[idx];
|
||||
const frameName = `frame-${String(idx).padStart(3, "0")}.${settings.format}`;
|
||||
ctx.report(
|
||||
Math.min(90, 5 + Math.round(((idx + 1) / stamps.length) * 85)),
|
||||
`Extracting frame ${idx + 1}/${stamps.length}`,
|
||||
);
|
||||
await runFfmpeg(
|
||||
["-ss", String(t), "-i", inPath, "-frames:v", "1", join(framesDir, frameName)],
|
||||
{ signal: ctx.signal, timeoutMs: 60_000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect produced frames
|
||||
const files = (await readdir(framesDir)).filter((f) => f.endsWith(`.${settings.format}`));
|
||||
files.sort();
|
||||
if (files.length === 0) {
|
||||
throw new Error("No frames extracted");
|
||||
}
|
||||
|
||||
// Zip frames (same pattern as split-pdf.ts)
|
||||
ctx.report(92, "Creating archive");
|
||||
const zipPath = join(ctx.scratchDir, "media", `${base}_frames.zip`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
output.on("close", () => resolve());
|
||||
archive.on("error", (err: Error) => reject(err));
|
||||
archive.pipe(output);
|
||||
for (const f of files) {
|
||||
archive.file(join(framesDir, f), { name: f });
|
||||
}
|
||||
void archive.finalize();
|
||||
});
|
||||
|
||||
return {
|
||||
scratchPath: zipPath,
|
||||
filename: `${base}_frames.zip`,
|
||||
contentType: "application/zip",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
fps: z.number().int().min(1).max(30).default(12),
|
||||
width: z.number().int().min(16).max(1920).default(480),
|
||||
quality: z.number().int().min(1).max(100).default(75),
|
||||
loop: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export function registerVideoToWebp(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "video-to-webp",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("video-to-webp is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}.webp`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
|
||||
return [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
`fps=${settings.fps},scale=${settings.width}:-2:flags=lanczos`,
|
||||
"-c:v",
|
||||
"libwebp_anim",
|
||||
"-quality",
|
||||
String(settings.quality),
|
||||
"-loop",
|
||||
settings.loop ? "0" : "1",
|
||||
"-an",
|
||||
out,
|
||||
];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: "image/webp" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { resolveEncoder, resolveFontFile } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { runMediaTool, videoContentType } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
text: z.string().min(1).max(200),
|
||||
position: z.enum(["tl", "tc", "tr", "l", "c", "r", "bl", "bc", "br"]).default("br"),
|
||||
fontSize: z.number().int().min(8).max(120).default(36),
|
||||
opacity: z.number().min(0.05).max(1).default(0.5),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#ffffff"),
|
||||
});
|
||||
|
||||
const POS: Record<string, string> = {
|
||||
tl: "x=24:y=24",
|
||||
tc: "x=(w-tw)/2:y=24",
|
||||
tr: "x=w-tw-24:y=24",
|
||||
l: "x=24:y=(h-th)/2",
|
||||
c: "x=(w-tw)/2:y=(h-th)/2",
|
||||
r: "x=w-tw-24:y=(h-th)/2",
|
||||
bl: "x=24:y=h-th-24",
|
||||
bc: "x=(w-tw)/2:y=h-th-24",
|
||||
br: "x=w-tw-24:y=h-th-24",
|
||||
};
|
||||
|
||||
export function registerWatermarkVideo(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "watermark-video",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("watermark-video is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp4";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_watermarked${origExt}`;
|
||||
const contentType = videoContentType(origExt);
|
||||
|
||||
const font = resolveFontFile();
|
||||
if (!font) {
|
||||
throw new Error("No usable font found for watermarking (set SNAPOTTER_FONT_FILE)");
|
||||
}
|
||||
|
||||
const textFile = join(ctx.scratchDir, "wm-text.txt");
|
||||
await writeFile(textFile, settings.text, "utf8");
|
||||
|
||||
const color = `${settings.color.replace("#", "0x")}@${settings.opacity.toFixed(2)}`;
|
||||
|
||||
// expansion=none: user text is literal; %{...} sequences must not expand
|
||||
const vf = `drawtext=fontfile=${font.file}:textfile=${textFile}:fontsize=${settings.fontSize}:fontcolor=${color}:${POS[settings.position]}:expansion=none`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [
|
||||
"-i",
|
||||
inPath,
|
||||
"-vf",
|
||||
vf,
|
||||
"-c:v",
|
||||
resolveEncoder("h264"),
|
||||
"-crf",
|
||||
"20",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"copy",
|
||||
out,
|
||||
]);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Target = "16:9" | "9:16" | "1:1" | "4:3" | "3:4";
|
||||
|
||||
export function AspectPadSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["aspect-pad"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("aspect-pad");
|
||||
|
||||
const [target, setTarget] = useState<Target>("9:16");
|
||||
const [color, setColor] = useState("#000000");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { target, color };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ap-target" className="text-xs text-muted-foreground">
|
||||
{s.target}
|
||||
</label>
|
||||
<select
|
||||
id="ap-target"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value as Target)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="9:16">9:16</option>
|
||||
<option value="1:1">1:1</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="3:4">3:4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ap-color" className="text-xs text-muted-foreground">
|
||||
{s.color}
|
||||
</label>
|
||||
<input
|
||||
id="ap-color"
|
||||
type="text"
|
||||
maxLength={7}
|
||||
placeholder="#RRGGBB"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.colorHint}</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="aspect-pad-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Target = "16:9" | "9:16" | "1:1" | "4:3" | "3:4";
|
||||
|
||||
export function BlurPadSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["blur-pad"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("blur-pad");
|
||||
|
||||
const [target, setTarget] = useState<Target>("16:9");
|
||||
const [blur, setBlur] = useState(20);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { target, blur };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="bp-target" className="text-xs text-muted-foreground">
|
||||
{s.target}
|
||||
</label>
|
||||
<select
|
||||
id="bp-target"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value as Target)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="9:16">9:16</option>
|
||||
<option value="1:1">1:1</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="3:4">3:4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="bp-blur" className="text-xs text-muted-foreground">
|
||||
{s.blur}
|
||||
</label>
|
||||
<input
|
||||
id="bp-blur"
|
||||
type="number"
|
||||
min={2}
|
||||
max={50}
|
||||
step={1}
|
||||
value={blur}
|
||||
onChange={(e) => setBlur(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="blur-pad-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function BurnSubtitlesSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["burn-subtitles"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, progress } = useToolProcessor("burn-subtitles");
|
||||
|
||||
const [fontSize, setFontSize] = useState(24);
|
||||
|
||||
const hasEnough = files.length >= 2;
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { fontSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="bs-font-size" className="text-xs text-muted-foreground">
|
||||
{s.fontSize}
|
||||
</label>
|
||||
<input
|
||||
id="bs-font-size"
|
||||
type="number"
|
||||
min={8}
|
||||
max={72}
|
||||
step={1}
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="burn-subtitles-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasEnough || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function ChangeFpsSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["change-fps"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("change-fps");
|
||||
|
||||
const [fps, setFps] = useState(30);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { fps };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cfps-fps" className="text-xs text-muted-foreground">
|
||||
{s.fps}
|
||||
</label>
|
||||
<input
|
||||
id="cfps-fps"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
step={1}
|
||||
value={fps}
|
||||
onChange={(e) => setFps(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="change-fps-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function CropVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["crop-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("crop-video");
|
||||
|
||||
const [width, setWidth] = useState<number | "">("");
|
||||
const [height, setHeight] = useState<number | "">("");
|
||||
const [x, setX] = useState(0);
|
||||
const [y, setY] = useState(0);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
const dimsValid = width !== "" && height !== "";
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { width, height, x, y };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="crv-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="crv-width"
|
||||
type="number"
|
||||
min={16}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="crv-height" className="text-xs text-muted-foreground">
|
||||
{s.height}
|
||||
</label>
|
||||
<input
|
||||
id="crv-height"
|
||||
type="number"
|
||||
min={16}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="crv-x" className="text-xs text-muted-foreground">
|
||||
{s.x}
|
||||
</label>
|
||||
<input
|
||||
id="crv-x"
|
||||
type="number"
|
||||
min={0}
|
||||
value={x}
|
||||
onChange={(e) => setX(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="crv-y" className="text-xs text-muted-foreground">
|
||||
{s.y}
|
||||
</label>
|
||||
<input
|
||||
id="crv-y"
|
||||
type="number"
|
||||
min={0}
|
||||
value={y}
|
||||
onChange={(e) => setY(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing || !dimsValid}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function EmbedSubtitlesSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["embed-subtitles"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, progress } = useToolProcessor("embed-subtitles");
|
||||
|
||||
const [language, setLanguage] = useState("eng");
|
||||
|
||||
const hasEnough = files.length >= 2;
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { language });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="es-language" className="text-xs text-muted-foreground">
|
||||
{s.language}
|
||||
</label>
|
||||
<input
|
||||
id="es-language"
|
||||
type="text"
|
||||
maxLength={3}
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.languageHint}</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="embed-subtitles-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasEnough || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function ExtractSubtitlesSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["extract-subtitles"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("extract-subtitles");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = {};
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="extract-subtitles-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Format = "mp4" | "webm";
|
||||
|
||||
export function GifToVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["gif-to-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("gif-to-video");
|
||||
|
||||
const [fmt, setFmt] = useState<Format>("mp4");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { format: fmt };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="g2v-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="g2v-format"
|
||||
value={fmt}
|
||||
onChange={(e) => setFmt(e.target.value as Format)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="gif-to-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Resolution = "1080p" | "720p" | "square";
|
||||
|
||||
export function ImagesToVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["images-to-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, progress } = useToolProcessor("images-to-video");
|
||||
|
||||
const [secondsPerImage, setSecondsPerImage] = useState(2);
|
||||
const [resolution, setResolution] = useState<Resolution>("720p");
|
||||
const [fps, setFps] = useState(30);
|
||||
|
||||
const hasEnough = files.length >= 2;
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { secondsPerImage, resolution, fps });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="i2v-spi" className="text-xs text-muted-foreground">
|
||||
{s.secondsPerImage}
|
||||
</label>
|
||||
<input
|
||||
id="i2v-spi"
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={secondsPerImage}
|
||||
onChange={(e) => setSecondsPerImage(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="i2v-resolution" className="text-xs text-muted-foreground">
|
||||
{s.resolution}
|
||||
</label>
|
||||
<select
|
||||
id="i2v-resolution"
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value as Resolution)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="1080p">1080p (1920x1080)</option>
|
||||
<option value="720p">720p (1280x720)</option>
|
||||
<option value="square">Square (1080x1080)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="i2v-fps" className="text-xs text-muted-foreground">
|
||||
{s.fps}
|
||||
</label>
|
||||
<input
|
||||
id="i2v-fps"
|
||||
type="number"
|
||||
min={10}
|
||||
max={60}
|
||||
step={1}
|
||||
value={fps}
|
||||
onChange={(e) => setFps(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="images-to-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasEnough || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function MergeVideosSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["merge-videos"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, progress } = useToolProcessor("merge-videos");
|
||||
|
||||
const hasEnough = files.length >= 2;
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-videos-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasEnough || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function ReplaceAudioSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["replace-audio"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, progress } = useToolProcessor("replace-audio");
|
||||
|
||||
const hasEnough = files.length >= 2;
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="replace-audio-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasEnough || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Preset = "custom" | "2160p" | "1440p" | "1080p" | "720p" | "480p" | "360p";
|
||||
|
||||
export function ResizeVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["resize-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("resize-video");
|
||||
|
||||
const [preset, setPreset] = useState<Preset>("custom");
|
||||
const [width, setWidth] = useState<number | "">("");
|
||||
const [height, setHeight] = useState<number | "">("");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
const isCustom = preset === "custom";
|
||||
const customValid = !isCustom || width !== "" || height !== "";
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { preset };
|
||||
if (isCustom) {
|
||||
if (width !== "") settings.width = width;
|
||||
if (height !== "") settings.height = height;
|
||||
}
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rv-preset" className="text-xs text-muted-foreground">
|
||||
{s.preset}
|
||||
</label>
|
||||
<select
|
||||
id="rv-preset"
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as Preset)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="2160p">2160p (4K)</option>
|
||||
<option value="1440p">1440p (2K)</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="480p">480p</option>
|
||||
<option value="360p">360p</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isCustom && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="rv-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="rv-width"
|
||||
type="number"
|
||||
min={16}
|
||||
max={7680}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="rv-height" className="text-xs text-muted-foreground">
|
||||
{s.height}
|
||||
</label>
|
||||
<input
|
||||
id="rv-height"
|
||||
type="number"
|
||||
min={16}
|
||||
max={4320}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="resize-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing || !customValid}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function ReverseVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["reverse-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("reverse-video");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = {};
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reverse-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Transform = "cw90" | "ccw90" | "180" | "hflip" | "vflip";
|
||||
|
||||
export function RotateVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["rotate-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("rotate-video");
|
||||
|
||||
const [transform, setTransform] = useState<Transform>("cw90");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { transform };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rtv-transform" className="text-xs text-muted-foreground">
|
||||
{s.transform}
|
||||
</label>
|
||||
<select
|
||||
id="rtv-transform"
|
||||
value={transform}
|
||||
onChange={(e) => setTransform(e.target.value as Transform)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="cw90">{s.cw90}</option>
|
||||
<option value="ccw90">{s.ccw90}</option>
|
||||
<option value="180">{s.rotate180}</option>
|
||||
<option value="hflip">{s.hflip}</option>
|
||||
<option value="vflip">{s.vflip}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rotate-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function StabilizeVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["stabilize-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("stabilize-video");
|
||||
|
||||
const [smoothing, setSmoothing] = useState(15);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { smoothing };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="sv-smoothing" className="text-xs text-muted-foreground">
|
||||
{s.smoothing}
|
||||
</label>
|
||||
<input
|
||||
id="sv-smoothing"
|
||||
type="number"
|
||||
min={5}
|
||||
max={60}
|
||||
step={1}
|
||||
value={smoothing}
|
||||
onChange={(e) => setSmoothing(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stabilize-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function VideoColorSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-color"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("video-color");
|
||||
|
||||
const [brightness, setBrightness] = useState(0);
|
||||
const [contrast, setContrast] = useState(1);
|
||||
const [saturation, setSaturation] = useState(1);
|
||||
const [gamma, setGamma] = useState(1);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { brightness, contrast, saturation, gamma };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="vc-brightness" className="text-xs text-muted-foreground">
|
||||
{s.brightness}
|
||||
</label>
|
||||
<input
|
||||
id="vc-brightness"
|
||||
type="number"
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={brightness}
|
||||
onChange={(e) => setBrightness(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="vc-contrast" className="text-xs text-muted-foreground">
|
||||
{s.contrast}
|
||||
</label>
|
||||
<input
|
||||
id="vc-contrast"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.1}
|
||||
value={contrast}
|
||||
onChange={(e) => setContrast(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="vc-saturation" className="text-xs text-muted-foreground">
|
||||
{s.saturation}
|
||||
</label>
|
||||
<input
|
||||
id="vc-saturation"
|
||||
type="number"
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={saturation}
|
||||
onChange={(e) => setSaturation(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="vc-gamma" className="text-xs text-muted-foreground">
|
||||
{s.gamma}
|
||||
</label>
|
||||
<input
|
||||
id="vc-gamma"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={gamma}
|
||||
onChange={(e) => setGamma(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-color-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function VideoLoudnormSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-loudnorm"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("video-loudnorm");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = {};
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-loudnorm-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function VideoMetadataSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-metadata"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("video-metadata");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = {};
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-metadata-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function VideoSpeedSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-speed"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("video-speed");
|
||||
|
||||
const [factor, setFactor] = useState(2);
|
||||
const [keepPitch, setKeepPitch] = useState(true);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { factor, keepPitch };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="vs-factor" className="text-xs text-muted-foreground">
|
||||
{s.factor}
|
||||
</label>
|
||||
<input
|
||||
id="vs-factor"
|
||||
type="number"
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.25}
|
||||
value={factor}
|
||||
onChange={(e) => setFactor(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepPitch}
|
||||
onChange={(e) => setKeepPitch(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{s.keepPitch}
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-speed-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Mode = "all" | "nth" | "timestamps";
|
||||
type ImgFormat = "png" | "jpg";
|
||||
|
||||
export function VideoToFramesSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-to-frames"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("video-to-frames");
|
||||
|
||||
const [mode, setMode] = useState<Mode>("all");
|
||||
const [n, setN] = useState(10);
|
||||
const [timestamps, setTimestamps] = useState("");
|
||||
const [imgFormat, setImgFormat] = useState<ImgFormat>("png");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { mode, format: imgFormat };
|
||||
if (mode === "nth") settings.n = n;
|
||||
if (mode === "timestamps") settings.timestamps = timestamps;
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="v2f-mode" className="text-xs text-muted-foreground">
|
||||
{s.mode}
|
||||
</label>
|
||||
<select
|
||||
id="v2f-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as Mode)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="all">{s.modeAll}</option>
|
||||
<option value="nth">{s.modeNth}</option>
|
||||
<option value="timestamps">{s.modeTimestamps}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{mode === "nth" && (
|
||||
<div>
|
||||
<label htmlFor="v2f-n" className="text-xs text-muted-foreground">
|
||||
{s.n}
|
||||
</label>
|
||||
<input
|
||||
id="v2f-n"
|
||||
type="number"
|
||||
min={2}
|
||||
max={1000}
|
||||
step={1}
|
||||
value={n}
|
||||
onChange={(e) => setN(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "timestamps" && (
|
||||
<div>
|
||||
<label htmlFor="v2f-timestamps" className="text-xs text-muted-foreground">
|
||||
{s.timestamps}
|
||||
</label>
|
||||
<input
|
||||
id="v2f-timestamps"
|
||||
type="text"
|
||||
value={timestamps}
|
||||
onChange={(e) => setTimestamps(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.timestampsHint}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="v2f-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="v2f-format"
|
||||
value={imgFormat}
|
||||
onChange={(e) => setImgFormat(e.target.value as ImgFormat)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="png">PNG</option>
|
||||
<option value="jpg">JPG</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-to-frames-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function VideoToWebpSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-to-webp"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("video-to-webp");
|
||||
|
||||
const [fps, setFps] = useState(12);
|
||||
const [width, setWidth] = useState(480);
|
||||
const [quality, setQuality] = useState(75);
|
||||
const [loop, setLoop] = useState(true);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { fps, width, quality, loop };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="v2w-fps" className="text-xs text-muted-foreground">
|
||||
{s.fps}
|
||||
</label>
|
||||
<input
|
||||
id="v2w-fps"
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
value={fps}
|
||||
onChange={(e) => setFps(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="v2w-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="v2w-width"
|
||||
type="number"
|
||||
min={16}
|
||||
max={1920}
|
||||
step={1}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="v2w-quality" className="text-xs text-muted-foreground">
|
||||
{s.quality}
|
||||
</label>
|
||||
<input
|
||||
id="v2w-quality"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={loop}
|
||||
onChange={(e) => setLoop(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{s.loop}
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="video-to-webp-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Position = "tl" | "tc" | "tr" | "l" | "c" | "r" | "bl" | "bc" | "br";
|
||||
|
||||
export function WatermarkVideoSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["watermark-video"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("watermark-video");
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const [position, setPosition] = useState<Position>("br");
|
||||
const [fontSize, setFontSize] = useState(36);
|
||||
const [opacity, setOpacity] = useState(0.5);
|
||||
const [color, setColor] = useState("#ffffff");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { text, position, fontSize, opacity, color };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="wmv-text" className="text-xs text-muted-foreground">
|
||||
{s.text}
|
||||
</label>
|
||||
<input
|
||||
id="wmv-text"
|
||||
type="text"
|
||||
maxLength={200}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="wmv-position" className="text-xs text-muted-foreground">
|
||||
{s.position}
|
||||
</label>
|
||||
<select
|
||||
id="wmv-position"
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value as Position)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="tl">Top Left</option>
|
||||
<option value="tc">Top Center</option>
|
||||
<option value="tr">Top Right</option>
|
||||
<option value="l">Left</option>
|
||||
<option value="c">Center</option>
|
||||
<option value="r">Right</option>
|
||||
<option value="bl">Bottom Left</option>
|
||||
<option value="bc">Bottom Center</option>
|
||||
<option value="br">Bottom Right</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="wmv-font-size" className="text-xs text-muted-foreground">
|
||||
{s.fontSize}
|
||||
</label>
|
||||
<input
|
||||
id="wmv-font-size"
|
||||
type="number"
|
||||
min={8}
|
||||
max={120}
|
||||
step={1}
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="wmv-opacity" className="text-xs text-muted-foreground">
|
||||
{s.opacity}
|
||||
</label>
|
||||
<input
|
||||
id="wmv-opacity"
|
||||
type="number"
|
||||
min={0.05}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="wmv-color" className="text-xs text-muted-foreground">
|
||||
{s.color}
|
||||
</label>
|
||||
<input
|
||||
id="wmv-color"
|
||||
type="text"
|
||||
maxLength={7}
|
||||
placeholder="#RRGGBB"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.colorHint}</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={s.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="watermark-video-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing || !text}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Anchor,
|
||||
AppWindow,
|
||||
Archive,
|
||||
AudioLines,
|
||||
BookImage,
|
||||
BookOpen,
|
||||
Braces,
|
||||
Captions,
|
||||
CheckCircle2,
|
||||
Code,
|
||||
Columns2,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
Expand,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FastForward,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
@@ -33,11 +36,15 @@ import {
|
||||
Film,
|
||||
Focus,
|
||||
Frame,
|
||||
GalleryHorizontalEnd,
|
||||
GalleryThumbnails,
|
||||
Gauge,
|
||||
Globe,
|
||||
Grid3x3,
|
||||
Hash,
|
||||
Image,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Info,
|
||||
Laugh,
|
||||
Layers,
|
||||
@@ -47,12 +54,16 @@ import {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Maximize2,
|
||||
MessageSquareText,
|
||||
MicVocal,
|
||||
Minimize2,
|
||||
Palette,
|
||||
PenLine,
|
||||
PenTool,
|
||||
Pipette,
|
||||
QrCode,
|
||||
RectangleHorizontal,
|
||||
Rewind,
|
||||
RotateCw,
|
||||
Scaling,
|
||||
ScanEye,
|
||||
@@ -74,6 +85,7 @@ import {
|
||||
Undo2,
|
||||
UserCheck,
|
||||
Video,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Wand,
|
||||
Wrench,
|
||||
@@ -84,12 +96,14 @@ import {
|
||||
// Only the icons actually used by tool definitions in @snapotter/shared constants.
|
||||
// Avoids `import * as icons from "lucide-react"` which pulls the entire 1000+ icon library.
|
||||
export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Anchor,
|
||||
AppWindow,
|
||||
Archive,
|
||||
AudioLines,
|
||||
BookImage,
|
||||
BookOpen,
|
||||
Braces,
|
||||
Captions,
|
||||
CheckCircle2,
|
||||
Code,
|
||||
Columns2,
|
||||
@@ -103,6 +117,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Expand,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FastForward,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
@@ -117,11 +132,15 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Film,
|
||||
Focus,
|
||||
Frame,
|
||||
GalleryHorizontalEnd,
|
||||
GalleryThumbnails,
|
||||
Gauge,
|
||||
Globe,
|
||||
Grid3x3,
|
||||
Hash,
|
||||
Image,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Info,
|
||||
Laugh,
|
||||
Layers,
|
||||
@@ -130,12 +149,16 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Maximize2,
|
||||
MessageSquareText,
|
||||
MicVocal,
|
||||
Minimize2,
|
||||
Palette,
|
||||
PenLine,
|
||||
PenTool,
|
||||
Pipette,
|
||||
QrCode,
|
||||
RectangleHorizontal,
|
||||
Rewind,
|
||||
RotateCw,
|
||||
Scaling,
|
||||
Scissors,
|
||||
@@ -156,6 +179,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Undo2,
|
||||
UserCheck,
|
||||
Video,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Wand,
|
||||
Wrench,
|
||||
|
||||
@@ -97,6 +97,28 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
"trim-video": "media-player",
|
||||
"mute-video": "media-player",
|
||||
"video-to-gif": "side-by-side",
|
||||
"resize-video": "media-player",
|
||||
"crop-video": "media-player",
|
||||
"rotate-video": "media-player",
|
||||
"change-fps": "media-player",
|
||||
"video-color": "media-player",
|
||||
"video-speed": "media-player",
|
||||
"reverse-video": "media-player",
|
||||
"video-loudnorm": "media-player",
|
||||
"aspect-pad": "media-player",
|
||||
"blur-pad": "media-player",
|
||||
"watermark-video": "media-player",
|
||||
"stabilize-video": "media-player",
|
||||
"gif-to-video": "media-player",
|
||||
"video-to-webp": "side-by-side",
|
||||
"video-to-frames": "no-comparison",
|
||||
"merge-videos": "media-player",
|
||||
"replace-audio": "media-player",
|
||||
"burn-subtitles": "media-player",
|
||||
"embed-subtitles": "media-player",
|
||||
"extract-subtitles": "no-comparison",
|
||||
"images-to-video": "media-player",
|
||||
"video-metadata": "no-comparison",
|
||||
|
||||
// Audio tools
|
||||
"convert-audio": "media-player",
|
||||
@@ -142,4 +164,11 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
* Tools whose selected files all post in ONE request as repeated "file" parts.
|
||||
* Consumed by use-tool-processor; backend routes declare maxInputs.
|
||||
*/
|
||||
export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set(["merge-pdf"]);
|
||||
export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set([
|
||||
"merge-pdf",
|
||||
"merge-videos",
|
||||
"replace-audio",
|
||||
"burn-subtitles",
|
||||
"embed-subtitles",
|
||||
"images-to-video",
|
||||
]);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Adding a new tool means adding one entry here instead of editing a 750-line file.
|
||||
*/
|
||||
|
||||
import { AUDIO_INPUTS, VIDEO_INPUTS } from "@snapotter/shared";
|
||||
import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "@snapotter/shared";
|
||||
import type React from "react";
|
||||
import { lazy } from "react";
|
||||
import type { Crop } from "react-image-crop";
|
||||
@@ -369,6 +369,116 @@ const VideoToGifSettings = lazy(() =>
|
||||
default: m.VideoToGifSettings,
|
||||
})),
|
||||
);
|
||||
const ResizeVideoSettings = lazy(() =>
|
||||
import("@/components/tools/resize-video-settings").then((m) => ({
|
||||
default: m.ResizeVideoSettings,
|
||||
})),
|
||||
);
|
||||
const CropVideoSettings = lazy(() =>
|
||||
import("@/components/tools/crop-video-settings").then((m) => ({
|
||||
default: m.CropVideoSettings,
|
||||
})),
|
||||
);
|
||||
const RotateVideoSettings = lazy(() =>
|
||||
import("@/components/tools/rotate-video-settings").then((m) => ({
|
||||
default: m.RotateVideoSettings,
|
||||
})),
|
||||
);
|
||||
const ChangeFpsSettings = lazy(() =>
|
||||
import("@/components/tools/change-fps-settings").then((m) => ({
|
||||
default: m.ChangeFpsSettings,
|
||||
})),
|
||||
);
|
||||
const VideoColorSettings = lazy(() =>
|
||||
import("@/components/tools/video-color-settings").then((m) => ({
|
||||
default: m.VideoColorSettings,
|
||||
})),
|
||||
);
|
||||
const VideoSpeedSettings = lazy(() =>
|
||||
import("@/components/tools/video-speed-settings").then((m) => ({
|
||||
default: m.VideoSpeedSettings,
|
||||
})),
|
||||
);
|
||||
const ReverseVideoSettings = lazy(() =>
|
||||
import("@/components/tools/reverse-video-settings").then((m) => ({
|
||||
default: m.ReverseVideoSettings,
|
||||
})),
|
||||
);
|
||||
const VideoLoudnormSettings = lazy(() =>
|
||||
import("@/components/tools/video-loudnorm-settings").then((m) => ({
|
||||
default: m.VideoLoudnormSettings,
|
||||
})),
|
||||
);
|
||||
const AspectPadSettings = lazy(() =>
|
||||
import("@/components/tools/aspect-pad-settings").then((m) => ({
|
||||
default: m.AspectPadSettings,
|
||||
})),
|
||||
);
|
||||
const BlurPadSettings = lazy(() =>
|
||||
import("@/components/tools/blur-pad-settings").then((m) => ({
|
||||
default: m.BlurPadSettings,
|
||||
})),
|
||||
);
|
||||
const WatermarkVideoSettings = lazy(() =>
|
||||
import("@/components/tools/watermark-video-settings").then((m) => ({
|
||||
default: m.WatermarkVideoSettings,
|
||||
})),
|
||||
);
|
||||
const StabilizeVideoSettings = lazy(() =>
|
||||
import("@/components/tools/stabilize-video-settings").then((m) => ({
|
||||
default: m.StabilizeVideoSettings,
|
||||
})),
|
||||
);
|
||||
const GifToVideoSettings = lazy(() =>
|
||||
import("@/components/tools/gif-to-video-settings").then((m) => ({
|
||||
default: m.GifToVideoSettings,
|
||||
})),
|
||||
);
|
||||
const VideoToWebpSettings = lazy(() =>
|
||||
import("@/components/tools/video-to-webp-settings").then((m) => ({
|
||||
default: m.VideoToWebpSettings,
|
||||
})),
|
||||
);
|
||||
const VideoToFramesSettings = lazy(() =>
|
||||
import("@/components/tools/video-to-frames-settings").then((m) => ({
|
||||
default: m.VideoToFramesSettings,
|
||||
})),
|
||||
);
|
||||
const MergeVideosSettings = lazy(() =>
|
||||
import("@/components/tools/merge-videos-settings").then((m) => ({
|
||||
default: m.MergeVideosSettings,
|
||||
})),
|
||||
);
|
||||
const ReplaceAudioSettings = lazy(() =>
|
||||
import("@/components/tools/replace-audio-settings").then((m) => ({
|
||||
default: m.ReplaceAudioSettings,
|
||||
})),
|
||||
);
|
||||
const BurnSubtitlesSettings = lazy(() =>
|
||||
import("@/components/tools/burn-subtitles-settings").then((m) => ({
|
||||
default: m.BurnSubtitlesSettings,
|
||||
})),
|
||||
);
|
||||
const EmbedSubtitlesSettings = lazy(() =>
|
||||
import("@/components/tools/embed-subtitles-settings").then((m) => ({
|
||||
default: m.EmbedSubtitlesSettings,
|
||||
})),
|
||||
);
|
||||
const ExtractSubtitlesSettings = lazy(() =>
|
||||
import("@/components/tools/extract-subtitles-settings").then((m) => ({
|
||||
default: m.ExtractSubtitlesSettings,
|
||||
})),
|
||||
);
|
||||
const ImagesToVideoSettings = lazy(() =>
|
||||
import("@/components/tools/images-to-video-settings").then((m) => ({
|
||||
default: m.ImagesToVideoSettings,
|
||||
})),
|
||||
);
|
||||
const VideoMetadataSettings = lazy(() =>
|
||||
import("@/components/tools/video-metadata-settings").then((m) => ({
|
||||
default: m.VideoMetadataSettings,
|
||||
})),
|
||||
);
|
||||
const ConvertAudioSettings = lazy(() =>
|
||||
import("@/components/tools/convert-audio-settings").then((m) => ({
|
||||
default: m.ConvertAudioSettings,
|
||||
@@ -653,6 +763,46 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["trim-video", { accept: VIDEO_INPUTS.join(","), Settings: TrimVideoSettings }],
|
||||
["mute-video", { accept: VIDEO_INPUTS.join(","), Settings: MuteVideoSettings }],
|
||||
["video-to-gif", { accept: VIDEO_INPUTS.join(","), Settings: VideoToGifSettings }],
|
||||
["resize-video", { accept: VIDEO_INPUTS.join(","), Settings: ResizeVideoSettings }],
|
||||
["crop-video", { accept: VIDEO_INPUTS.join(","), Settings: CropVideoSettings }],
|
||||
["rotate-video", { accept: VIDEO_INPUTS.join(","), Settings: RotateVideoSettings }],
|
||||
["change-fps", { accept: VIDEO_INPUTS.join(","), Settings: ChangeFpsSettings }],
|
||||
["video-color", { accept: VIDEO_INPUTS.join(","), Settings: VideoColorSettings }],
|
||||
["video-speed", { accept: VIDEO_INPUTS.join(","), Settings: VideoSpeedSettings }],
|
||||
["reverse-video", { accept: VIDEO_INPUTS.join(","), Settings: ReverseVideoSettings }],
|
||||
["video-loudnorm", { accept: VIDEO_INPUTS.join(","), Settings: VideoLoudnormSettings }],
|
||||
["aspect-pad", { accept: VIDEO_INPUTS.join(","), Settings: AspectPadSettings }],
|
||||
["blur-pad", { accept: VIDEO_INPUTS.join(","), Settings: BlurPadSettings }],
|
||||
["watermark-video", { accept: VIDEO_INPUTS.join(","), Settings: WatermarkVideoSettings }],
|
||||
["stabilize-video", { accept: VIDEO_INPUTS.join(","), Settings: StabilizeVideoSettings }],
|
||||
["gif-to-video", { accept: ".gif", Settings: GifToVideoSettings }],
|
||||
["video-to-webp", { accept: VIDEO_INPUTS.join(","), Settings: VideoToWebpSettings }],
|
||||
["video-to-frames", { accept: VIDEO_INPUTS.join(","), Settings: VideoToFramesSettings }],
|
||||
["merge-videos", { accept: VIDEO_INPUTS.join(","), Settings: MergeVideosSettings }],
|
||||
[
|
||||
"replace-audio",
|
||||
{
|
||||
accept: `${VIDEO_INPUTS.join(",")},${AUDIO_INPUTS.join(",")}`,
|
||||
Settings: ReplaceAudioSettings,
|
||||
},
|
||||
],
|
||||
[
|
||||
"burn-subtitles",
|
||||
{
|
||||
accept: `${VIDEO_INPUTS.join(",")},${SUBTITLE_INPUTS.join(",")}`,
|
||||
Settings: BurnSubtitlesSettings,
|
||||
},
|
||||
],
|
||||
[
|
||||
"embed-subtitles",
|
||||
{
|
||||
accept: `${VIDEO_INPUTS.join(",")},${SUBTITLE_INPUTS.join(",")}`,
|
||||
Settings: EmbedSubtitlesSettings,
|
||||
},
|
||||
],
|
||||
["extract-subtitles", { accept: VIDEO_INPUTS.join(","), Settings: ExtractSubtitlesSettings }],
|
||||
["images-to-video", { accept: IMAGE_INPUTS.join(","), Settings: ImagesToVideoSettings }],
|
||||
["video-metadata", { accept: VIDEO_INPUTS.join(","), Settings: VideoMetadataSettings }],
|
||||
|
||||
// Audio tools
|
||||
["convert-audio", { accept: AUDIO_INPUTS.join(","), Settings: ConvertAudioSettings }],
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface MediaStreamInfo {
|
||||
codec: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
export interface MediaInfo {
|
||||
@@ -70,7 +71,13 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro
|
||||
});
|
||||
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 }>;
|
||||
streams?: Array<{
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sample_rate?: string;
|
||||
}>;
|
||||
};
|
||||
const duration = parsed.format?.duration ? Number(parsed.format.duration) : null;
|
||||
const bitRate = parsed.format?.bit_rate ? Number(parsed.format.bit_rate) : null;
|
||||
@@ -78,11 +85,15 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro
|
||||
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,
|
||||
})),
|
||||
streams: (parsed.streams ?? []).map((s) => {
|
||||
const sr = s.sample_rate ? Number(s.sample_rate) : undefined;
|
||||
return {
|
||||
type: s.codec_type === "video" ? "video" : s.codec_type === "audio" ? "audio" : "other",
|
||||
codec: s.codec_name ?? "unknown",
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
sampleRate: Number.isFinite(sr) && (sr as number) > 0 ? sr : undefined,
|
||||
} as MediaStreamInfo;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export interface FontRef {
|
||||
file: string;
|
||||
family: string;
|
||||
}
|
||||
|
||||
const CANDIDATES: FontRef[] = [
|
||||
// Debian/Ubuntu container (fonts-dejavu-core, present since wave 2)
|
||||
{ file: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", family: "DejaVu Sans" },
|
||||
// macOS development hosts
|
||||
{ file: "/System/Library/Fonts/Supplemental/Arial.ttf", family: "Arial" },
|
||||
{ file: "/System/Library/Fonts/Helvetica.ttc", family: "Helvetica" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Static ffmpeg builds carry no fontconfig database, so drawtext/libass
|
||||
* filters need an explicit font file. Env overrides first, then known
|
||||
* platform paths. Returns null when nothing exists (callers raise a clear
|
||||
* tool error instead of a cryptic ffmpeg one).
|
||||
*/
|
||||
export function resolveFontFile(): FontRef | null {
|
||||
const file = process.env.SNAPOTTER_FONT_FILE;
|
||||
if (file) {
|
||||
return { file, family: process.env.SNAPOTTER_FONT_FAMILY ?? "Sans" };
|
||||
}
|
||||
for (const c of CANDIDATES) {
|
||||
if (existsSync(c.file)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2,4 +2,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 FontRef, resolveFontFile } from "./fonts.js";
|
||||
export { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AUDIO_INPUTS, IMAGE_INPUTS, VIDEO_INPUTS } from "./modality.js";
|
||||
import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "./modality.js";
|
||||
import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js";
|
||||
|
||||
export const CATEGORIES: CategoryInfo[] = [
|
||||
@@ -666,6 +666,248 @@ export const TOOLS: Tool[] = [
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "resize-video",
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
category: "video",
|
||||
icon: "Scaling",
|
||||
route: "/resize-video",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "crop-video",
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
category: "video",
|
||||
icon: "Crop",
|
||||
route: "/crop-video",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "rotate-video",
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
category: "video",
|
||||
icon: "RotateCw",
|
||||
route: "/rotate-video",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "change-fps",
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
category: "video",
|
||||
icon: "Gauge",
|
||||
route: "/change-fps",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "video-color",
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
category: "video",
|
||||
icon: "SlidersHorizontal",
|
||||
route: "/video-color",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "video-speed",
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
category: "video",
|
||||
icon: "FastForward",
|
||||
route: "/video-speed",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "reverse-video",
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
category: "video",
|
||||
icon: "Rewind",
|
||||
route: "/reverse-video",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "video-loudnorm",
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
category: "video",
|
||||
icon: "Volume2",
|
||||
route: "/video-loudnorm",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "aspect-pad",
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
category: "video",
|
||||
icon: "RectangleHorizontal",
|
||||
route: "/aspect-pad",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "blur-pad",
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
category: "video",
|
||||
icon: "GalleryHorizontalEnd",
|
||||
route: "/blur-pad",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "watermark-video",
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
category: "video",
|
||||
icon: "Stamp",
|
||||
route: "/watermark-video",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "stabilize-video",
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
category: "video",
|
||||
icon: "Anchor",
|
||||
route: "/stabilize-video",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "gif-to-video",
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
category: "video",
|
||||
icon: "Film",
|
||||
route: "/gif-to-video",
|
||||
modality: "video",
|
||||
acceptedInputs: [".gif"],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "video-to-webp",
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
category: "video",
|
||||
icon: "Image",
|
||||
route: "/video-to-webp",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "video-to-frames",
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
category: "video",
|
||||
icon: "Images",
|
||||
route: "/video-to-frames",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "merge-videos",
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
category: "video",
|
||||
icon: "Combine",
|
||||
route: "/merge-videos",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "replace-audio",
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
category: "video",
|
||||
icon: "MicVocal",
|
||||
route: "/replace-audio",
|
||||
modality: "video",
|
||||
acceptedInputs: [...VIDEO_INPUTS, ...AUDIO_INPUTS],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "burn-subtitles",
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
category: "video",
|
||||
icon: "Captions",
|
||||
route: "/burn-subtitles",
|
||||
modality: "video",
|
||||
acceptedInputs: [...VIDEO_INPUTS, ...SUBTITLE_INPUTS],
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "embed-subtitles",
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
category: "video",
|
||||
icon: "MessageSquareText",
|
||||
route: "/embed-subtitles",
|
||||
modality: "video",
|
||||
acceptedInputs: [...VIDEO_INPUTS, ...SUBTITLE_INPUTS],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "extract-subtitles",
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
category: "video",
|
||||
icon: "FileText",
|
||||
route: "/extract-subtitles",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "images-to-video",
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
category: "video",
|
||||
icon: "GalleryThumbnails",
|
||||
route: "/images-to-video",
|
||||
modality: "video",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "video-metadata",
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
category: "video",
|
||||
icon: "Tags",
|
||||
route: "/video-metadata",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Audio
|
||||
{
|
||||
id: "convert-audio",
|
||||
|
||||
@@ -296,6 +296,94 @@ export const ar: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1431,6 +1519,175 @@ export const ar: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -301,6 +301,94 @@ export const de: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1444,6 +1532,175 @@ export const de: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -256,6 +256,94 @@ export const en = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1389,6 +1477,175 @@ export const en = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -286,6 +286,94 @@ export const es: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1426,6 +1514,175 @@ export const es: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -302,6 +302,94 @@ export const fr: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1445,6 +1533,175 @@ export const fr: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -293,6 +293,94 @@ export const hi: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1427,6 +1515,175 @@ export const hi: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -301,6 +301,94 @@ export const id: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1439,6 +1527,175 @@ export const id: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -300,6 +300,94 @@ export const it: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1438,6 +1526,175 @@ export const it: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -264,6 +264,94 @@ export const ja: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1397,6 +1485,175 @@ export const ja: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -251,6 +251,94 @@ export const ko: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1382,6 +1470,175 @@ export const ko: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -301,6 +301,94 @@ export const nl: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1441,6 +1529,175 @@ export const nl: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -302,6 +302,94 @@ export const pl: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1442,6 +1530,175 @@ export const pl: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -299,6 +299,94 @@ export const ptBR: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1438,6 +1526,175 @@ export const ptBR: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -301,6 +301,94 @@ export const ru: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1440,6 +1528,175 @@ export const ru: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -299,6 +299,94 @@ export const sv: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1437,6 +1525,175 @@ export const sv: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -294,6 +294,94 @@ export const th: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1420,6 +1508,175 @@ export const th: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -302,6 +302,94 @@ export const tr: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1442,6 +1530,175 @@ export const tr: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -301,6 +301,94 @@ export const uk: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1440,6 +1528,175 @@ export const uk: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -302,6 +302,94 @@ export const vi: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1439,6 +1527,175 @@ export const vi: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -251,6 +251,94 @@ export const zhCN: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1374,6 +1462,175 @@ export const zhCN: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -250,6 +250,94 @@ export const zhTW: TranslationKeys = {
|
||||
name: "Video to GIF",
|
||||
description: "Turn a video clip into an animated GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
name: "Resize Video",
|
||||
description: "Scale a video to a new resolution or preset size",
|
||||
},
|
||||
"crop-video": {
|
||||
name: "Crop Video",
|
||||
description: "Crop a region out of a video",
|
||||
},
|
||||
"rotate-video": {
|
||||
name: "Rotate Video",
|
||||
description: "Rotate or flip a video",
|
||||
},
|
||||
"change-fps": {
|
||||
name: "Change FPS",
|
||||
description: "Change the frame rate of a video",
|
||||
},
|
||||
"video-color": {
|
||||
name: "Video Color",
|
||||
description: "Adjust brightness, contrast, saturation, and gamma",
|
||||
},
|
||||
"video-speed": {
|
||||
name: "Video Speed",
|
||||
description: "Speed up or slow down a video",
|
||||
},
|
||||
"reverse-video": {
|
||||
name: "Reverse Video",
|
||||
description: "Play a video clip backwards",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
name: "Normalize Audio",
|
||||
description: "Normalize video audio volume to broadcast standard",
|
||||
},
|
||||
"aspect-pad": {
|
||||
name: "Aspect Pad",
|
||||
description: "Add solid-color bars to fit a target aspect ratio",
|
||||
},
|
||||
"blur-pad": {
|
||||
name: "Blur Pad",
|
||||
description: "Fill bars with a blurred copy of the video",
|
||||
},
|
||||
"watermark-video": {
|
||||
name: "Watermark Video",
|
||||
description: "Burn a text watermark onto video frames",
|
||||
},
|
||||
"stabilize-video": {
|
||||
name: "Stabilize Video",
|
||||
description: "Reduce camera shake with two-pass stabilization",
|
||||
},
|
||||
"gif-to-video": {
|
||||
name: "GIF to Video",
|
||||
description: "Convert an animated GIF into an MP4 or WebM video",
|
||||
},
|
||||
"video-to-webp": {
|
||||
name: "Video to WebP",
|
||||
description: "Convert a video clip into an animated WebP image",
|
||||
},
|
||||
"video-to-frames": {
|
||||
name: "Video to Frames",
|
||||
description: "Extract frames from a video as a ZIP of images",
|
||||
},
|
||||
"merge-videos": {
|
||||
name: "Merge Videos",
|
||||
description: "Join multiple video clips into one file",
|
||||
},
|
||||
"replace-audio": {
|
||||
name: "Replace Audio",
|
||||
description: "Swap the audio track of a video with another file",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
name: "Burn Subtitles",
|
||||
description: "Permanently render subtitles onto video frames",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
name: "Embed Subtitles",
|
||||
description: "Mux a subtitle track into the video container",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
name: "Extract Subtitles",
|
||||
description: "Pull the subtitle track out of a video as an SRT file",
|
||||
},
|
||||
"images-to-video": {
|
||||
name: "Images to Video",
|
||||
description: "Turn a set of images into a slideshow video",
|
||||
},
|
||||
"video-metadata": {
|
||||
name: "Clean Video Metadata",
|
||||
description: "Strip metadata from a video and report what was found",
|
||||
},
|
||||
"convert-audio": {
|
||||
name: "Convert Audio",
|
||||
description: "Convert audio between MP3, WAV, OGG, and more",
|
||||
@@ -1372,6 +1460,175 @@ export const zhTW: TranslationKeys = {
|
||||
submitBatch: "Create GIFs ({count} files)",
|
||||
progressLabel: "Creating GIF",
|
||||
},
|
||||
"resize-video": {
|
||||
preset: "Preset",
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
hint: "Leave one dimension empty to keep aspect ratio",
|
||||
submit: "Resize",
|
||||
submitBatch: "Resize ({count} files)",
|
||||
progressLabel: "Resizing",
|
||||
},
|
||||
"crop-video": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
x: "X offset",
|
||||
y: "Y offset",
|
||||
submit: "Crop",
|
||||
submitBatch: "Crop ({count} files)",
|
||||
progressLabel: "Cropping",
|
||||
},
|
||||
"rotate-video": {
|
||||
transform: "Transform",
|
||||
cw90: "Rotate 90 clockwise",
|
||||
ccw90: "Rotate 90 counter-clockwise",
|
||||
rotate180: "Rotate 180",
|
||||
hflip: "Flip horizontal",
|
||||
vflip: "Flip vertical",
|
||||
submit: "Rotate",
|
||||
submitBatch: "Rotate ({count} files)",
|
||||
progressLabel: "Rotating",
|
||||
},
|
||||
"change-fps": {
|
||||
fps: "Frames per second",
|
||||
submit: "Change FPS",
|
||||
submitBatch: "Change FPS ({count} files)",
|
||||
progressLabel: "Changing FPS",
|
||||
},
|
||||
"video-color": {
|
||||
brightness: "Brightness",
|
||||
contrast: "Contrast",
|
||||
saturation: "Saturation",
|
||||
gamma: "Gamma",
|
||||
submit: "Apply",
|
||||
submitBatch: "Apply ({count} files)",
|
||||
progressLabel: "Adjusting color",
|
||||
},
|
||||
"video-speed": {
|
||||
factor: "Speed factor",
|
||||
keepPitch: "Keep original pitch",
|
||||
submit: "Change speed",
|
||||
submitBatch: "Change speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"reverse-video": {
|
||||
hint: "Limited to clips up to 5 minutes",
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"video-loudnorm": {
|
||||
hint: "Normalizes audio to EBU R128 broadcast standard",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"aspect-pad": {
|
||||
target: "Target ratio",
|
||||
color: "Background color",
|
||||
colorHint: "Hex color, e.g. #000000",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"blur-pad": {
|
||||
target: "Target ratio",
|
||||
blur: "Blur strength",
|
||||
submit: "Pad",
|
||||
submitBatch: "Pad ({count} files)",
|
||||
progressLabel: "Padding",
|
||||
},
|
||||
"watermark-video": {
|
||||
text: "Watermark text",
|
||||
position: "Position",
|
||||
fontSize: "Font size",
|
||||
opacity: "Opacity",
|
||||
color: "Color",
|
||||
colorHint: "Hex color, e.g. #ffffff",
|
||||
submit: "Watermark",
|
||||
submitBatch: "Watermark ({count} files)",
|
||||
progressLabel: "Watermarking",
|
||||
},
|
||||
"stabilize-video": {
|
||||
smoothing: "Smoothing",
|
||||
hint: "Runs two passes and takes longer than most tools",
|
||||
submit: "Stabilize",
|
||||
submitBatch: "Stabilize ({count} files)",
|
||||
progressLabel: "Stabilizing",
|
||||
},
|
||||
"gif-to-video": {
|
||||
format: "Output format",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-webp": {
|
||||
fps: "Frames per second",
|
||||
width: "Width (px)",
|
||||
quality: "Quality",
|
||||
loop: "Loop animation",
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting",
|
||||
},
|
||||
"video-to-frames": {
|
||||
mode: "Extraction mode",
|
||||
modeAll: "All frames",
|
||||
modeNth: "Every Nth frame",
|
||||
modeTimestamps: "Specific timestamps",
|
||||
n: "Take every Nth frame",
|
||||
timestamps: "Timestamps (seconds)",
|
||||
timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5",
|
||||
format: "Image format",
|
||||
submit: "Extract Frames",
|
||||
submitBatch: "Extract Frames ({count} files)",
|
||||
progressLabel: "Extracting frames",
|
||||
},
|
||||
"merge-videos": {
|
||||
hint: "Files are joined in upload order",
|
||||
submit: "Merge",
|
||||
submitBatch: "Merge ({count} files)",
|
||||
progressLabel: "Merging",
|
||||
},
|
||||
"replace-audio": {
|
||||
hint: "Upload the video first, then the audio",
|
||||
submit: "Replace Audio",
|
||||
submitBatch: "Replace Audio ({count} files)",
|
||||
progressLabel: "Replacing audio",
|
||||
},
|
||||
"burn-subtitles": {
|
||||
fontSize: "Font size",
|
||||
hint: "Upload a video and a subtitle file (.srt or .vtt)",
|
||||
submit: "Burn Subtitles",
|
||||
submitBatch: "Burn Subtitles ({count} files)",
|
||||
progressLabel: "Burning subtitles",
|
||||
},
|
||||
"embed-subtitles": {
|
||||
language: "Language code",
|
||||
languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra",
|
||||
submit: "Embed Subtitles",
|
||||
submitBatch: "Embed Subtitles ({count} files)",
|
||||
progressLabel: "Embedding subtitles",
|
||||
},
|
||||
"extract-subtitles": {
|
||||
submit: "Extract Subtitles",
|
||||
submitBatch: "Extract Subtitles ({count} files)",
|
||||
progressLabel: "Extracting subtitles",
|
||||
},
|
||||
"images-to-video": {
|
||||
secondsPerImage: "Seconds per image",
|
||||
resolution: "Resolution",
|
||||
fps: "Frames per second",
|
||||
submit: "Create Video",
|
||||
submitBatch: "Create Video ({count} files)",
|
||||
progressLabel: "Creating slideshow",
|
||||
},
|
||||
"video-metadata": {
|
||||
hint: "Metadata report appears in the result",
|
||||
submit: "Strip Metadata",
|
||||
submitBatch: "Strip Metadata ({count} files)",
|
||||
progressLabel: "Stripping metadata",
|
||||
},
|
||||
"convert-audio": {
|
||||
format: "Output format",
|
||||
bitrate: "Bitrate (kbps)",
|
||||
|
||||
@@ -97,6 +97,7 @@ export const AUDIO_INPUTS = [
|
||||
".amr",
|
||||
".ac3",
|
||||
];
|
||||
export const SUBTITLE_INPUTS = [".srt", ".vtt", ".ass"] as const;
|
||||
export const DOCUMENT_INPUTS = [
|
||||
".pdf",
|
||||
".docx",
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:00,500
|
||||
SnapOtter subtitle one
|
||||
|
||||
2
|
||||
00:00:00,500 --> 00:00:01,000
|
||||
Second line
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:00.500
|
||||
SnapOtter subtitle one
|
||||
|
||||
00:00.500 --> 00:01.000
|
||||
Second line
|
||||
@@ -25,6 +25,10 @@ export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
|
||||
"unlock-pdf": { password: "test123" },
|
||||
"watermark-pdf": { text: "CONFIDENTIAL" },
|
||||
"redact-pdf": { terms: ["test"] },
|
||||
"crop-video": { width: 32, height: 32 },
|
||||
"rotate-video": { transform: "cw90" },
|
||||
"resize-video": { preset: "720p" },
|
||||
"watermark-video": { text: "CONFIDENTIAL" },
|
||||
};
|
||||
|
||||
export function defaultSettingsFor(toolId: string): unknown {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { canvasFor } from "../../apps/api/src/routes/tools/aspect-pad.js";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
let sourceW: number;
|
||||
let sourceH: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "apad-src-"));
|
||||
const srcFile = join(tmpDir, "tiny.mp4");
|
||||
writeFileSync(srcFile, MP4);
|
||||
const info = await probeMedia(srcFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
sourceW = v?.width ?? 0;
|
||||
sourceH = v?.height ?? 0;
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/aspect-pad",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("aspect-pad (requires ffmpeg)", () => {
|
||||
it("pads to 1:1 producing a square output", async () => {
|
||||
const res = await runTool({ target: "1:1" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "apad-test-"));
|
||||
const probeFile = join(tmpDir, "padded.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
expect(v?.width).toBe(v?.height);
|
||||
}, 60_000);
|
||||
|
||||
it("pads to 9:16 with dims matching canvasFor output", async () => {
|
||||
const res = await runTool({ target: "9:16" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "apad-test2-"));
|
||||
const probeFile = join(tmpDir, "padded916.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
|
||||
const expected = canvasFor(sourceW, sourceH, 9, 16);
|
||||
expect(v?.width).toBe(expected.cw);
|
||||
expect(v?.height).toBe(expected.ch);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects invalid color with 400", async () => {
|
||||
const res = await runTool({ color: "red" });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { canvasFor } from "../../apps/api/src/routes/tools/blur-pad.js";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
let sourceW: number;
|
||||
let sourceH: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "bpad-src-"));
|
||||
const srcFile = join(tmpDir, "tiny.mp4");
|
||||
writeFileSync(srcFile, MP4);
|
||||
const info = await probeMedia(srcFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
sourceW = v?.width ?? 0;
|
||||
sourceH = v?.height ?? 0;
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/blur-pad",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("blur-pad (requires ffmpeg)", () => {
|
||||
it("pads to 1:1 producing a square output", async () => {
|
||||
const res = await runTool({ target: "1:1" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "bpad-test-"));
|
||||
const probeFile = join(tmpDir, "blurpad.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
expect(v?.width).toBe(v?.height);
|
||||
}, 60_000);
|
||||
|
||||
it("pads to default 16:9 with dims matching canvasFor output", async () => {
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "bpad-test2-"));
|
||||
const probeFile = join(tmpDir, "blurpad169.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
|
||||
const expected = canvasFor(sourceW, sourceH, 16, 9);
|
||||
expect(v?.width).toBe(expected.cw);
|
||||
expect(v?.height).toBe(expected.ch);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
const SRT = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.srt"));
|
||||
const VTT = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.vtt"));
|
||||
const PNG = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
/**
|
||||
* Handles both the 200 sync-window path and the 202+poll path.
|
||||
* burn-subtitles has executionHint "long", so 202 is expected in most runs.
|
||||
*/
|
||||
async function resolveResult(res: Awaited<ReturnType<typeof testApp.app.inject>>): Promise<{
|
||||
path: "sync" | "poll";
|
||||
downloadPayload: Buffer;
|
||||
}> {
|
||||
if (res.statusCode === 200) {
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
return { path: "sync", downloadPayload: dl.rawPayload };
|
||||
}
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(res.body);
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
const { db, schema } = await import("../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
let row: { status: string; outputRefs: unknown } | undefined;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
|
||||
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
expect(row?.status).toBe("completed");
|
||||
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
return { path: "poll", downloadPayload: dl.rawPayload };
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("burn-subtitles (requires ffmpeg)", () => {
|
||||
it("burns SRT subtitles onto a video", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/burn-subtitles",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const { downloadPayload } = await resolveResult(res);
|
||||
expect(downloadPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "burn-srt-"));
|
||||
const probeFile = join(tmpDir, "burned.mp4");
|
||||
writeFileSync(probeFile, downloadPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.streams.some((s) => s.type === "video")).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("burns VTT subtitles onto a video", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "file", filename: "tiny.vtt", contentType: "text/vtt", content: VTT },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/burn-subtitles",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const { downloadPayload } = await resolveResult(res);
|
||||
expect(downloadPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "burn-vtt-"));
|
||||
const probeFile = join(tmpDir, "burned.mp4");
|
||||
writeFileSync(probeFile, downloadPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.streams.some((s) => s.type === "video")).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("rejects a PNG as the second file (subtitle kind rejection)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "file", filename: "image.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/burn-subtitles",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// The inputKinds seam validates input 1 as kind "subtitle".
|
||||
// A PNG fails the subtitle kind validation.
|
||||
expect(res.statusCode).toBe(400);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.error).toMatch(/subtitle/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/change-fps",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("change-fps (requires ffmpeg)", () => {
|
||||
it("changes to 10 fps and verifies via ffprobe", async () => {
|
||||
const res = await runTool({ fps: 10 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
// Write to tmp and probe frame rate directly via ffprobe spawnSync
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "fps-test-"));
|
||||
const probeFile = join(tmpDir, "fps.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const result = spawnSync("ffprobe", [
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=r_frame_rate",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
probeFile,
|
||||
]);
|
||||
const fps = result.stdout.toString().trim();
|
||||
expect(fps).toBe("10/1");
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/crop-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("crop-video (requires ffmpeg)", () => {
|
||||
it("crops to 32x32 and returns 200", async () => {
|
||||
const res = await runTool({ width: 32, height: 32, x: 0, y: 0 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "crop-test-"));
|
||||
const probeFile = join(tmpDir, "cropped.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
expect(v?.width).toBe(32);
|
||||
expect(v?.height).toBe(32);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects crop rect exceeding video dimensions with 422", async () => {
|
||||
const res = await runTool({ width: 9999, height: 9999 });
|
||||
expect(res.statusCode).toBe(422);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.details || body.error).toMatch(/exceeds video size/i);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects missing width with 400", async () => {
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
const SRT = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.srt"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("embed-subtitles (requires ffmpeg)", () => {
|
||||
it("embeds an SRT subtitle track into an MP4", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/embed-subtitles",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
// Probe the output: should have a non-av stream (subtitle mapped as "other")
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "embed-sub-"));
|
||||
const probeFile = join(tmpDir, "embedded.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.streams.some((s) => s.type === "other")).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects invalid language code 'english' with 400", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT },
|
||||
{ name: "settings", content: JSON.stringify({ language: "english" }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/embed-subtitles",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MKV = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny-subs.mkv"));
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("extract-subtitles (requires ffmpeg)", () => {
|
||||
it("extracts the subtitle track from an MKV with embedded subs", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny-subs.mkv", contentType: "video/x-matroska", content: MKV },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/extract-subtitles",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const srtContent = dl.rawPayload.toString("utf8");
|
||||
expect(srtContent).toContain("SnapOtter subtitle one");
|
||||
}, 60_000);
|
||||
|
||||
it("rejects a video with no subtitle track (422)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/extract-subtitles",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.details).toMatch(/no subtitle track/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const GIF = readFileSync(join(__dirname, "..", "fixtures", "animated.gif"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "animated.gif", contentType: "image/gif", content: GIF },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/gif-to-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("gif-to-video (requires ffmpeg)", () => {
|
||||
it("converts a GIF to mp4 by default and returns 200", async () => {
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
// Probe the downloaded file to verify it has a video stream
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "gif2vid-"));
|
||||
const probeFile = join(tmpDir, "out.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.streams.some((s) => s.type === "video")).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it("converts a GIF to webm when format is webm", async () => {
|
||||
const res = await runTool({ format: "webm" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "gif2webm-"));
|
||||
const probeFile = join(tmpDir, "out.webm");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.streams.some((s) => s.type === "video")).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const JPG = readFileSync(join(__dirname, "..", "fixtures", "test-100x100.jpg"));
|
||||
const PNG = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("images-to-video (requires ffmpeg)", () => {
|
||||
it("creates a slideshow from two images with default settings", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
|
||||
{ name: "file", filename: "photo.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/images-to-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "slideshow-"));
|
||||
const probeFile = join(tmpDir, "slideshow.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// Duration should be roughly 4s (2 images x 2s default, 30% tolerance)
|
||||
expect(info.durationS).not.toBeNull();
|
||||
const dur = info.durationS as number;
|
||||
expect(dur).toBeGreaterThan(4 * 0.7);
|
||||
expect(dur).toBeLessThan(4 * 1.3);
|
||||
|
||||
// Resolution should be 1280x720 (720p default)
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
expect(v).toBeDefined();
|
||||
expect(v?.width).toBe(1280);
|
||||
expect(v?.height).toBe(720);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects a single image with 422", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/images-to-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// fast hint: inline 422
|
||||
expect(res.statusCode).toBe(422);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.details).toMatch(/at least two images/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
/**
|
||||
* Handles both the 200 sync-window path and the 202+poll path.
|
||||
* merge-videos has executionHint "long", so expect 202 in most runs.
|
||||
*/
|
||||
async function resolveResult(res: Awaited<ReturnType<typeof testApp.app.inject>>): Promise<{
|
||||
path: "sync" | "poll";
|
||||
downloadPayload: Buffer;
|
||||
}> {
|
||||
if (res.statusCode === 200) {
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
return { path: "sync", downloadPayload: dl.rawPayload };
|
||||
}
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(res.body);
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
const { db, schema } = await import("../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
let row: { status: string; outputRefs: unknown } | undefined;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
|
||||
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
expect(row?.status).toBe("completed");
|
||||
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
return { path: "poll", downloadPayload: dl.rawPayload };
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("merge-videos (requires ffmpeg)", () => {
|
||||
it("merges two copies of tiny.mp4 into a single video", async () => {
|
||||
// Send TWO file parts named "file" (the multi-input path)
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "a.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "file", filename: "b.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/merge-videos",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const { path, downloadPayload } = await resolveResult(res);
|
||||
expect(downloadPayload.length).toBeGreaterThan(100);
|
||||
|
||||
// Probe the merged output: duration should be roughly 2x the source
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "merge-test-"));
|
||||
const probeFile = join(tmpDir, "merged.mp4");
|
||||
writeFileSync(probeFile, downloadPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// Probe the source for comparison
|
||||
const srcFile = join(tmpDir, "src.mp4");
|
||||
writeFileSync(srcFile, MP4);
|
||||
const srcInfo = await probeMedia(srcFile);
|
||||
const srcDuration = srcInfo.durationS ?? 0;
|
||||
|
||||
expect(info.durationS).not.toBeNull();
|
||||
const merged = info.durationS as number;
|
||||
// Within 30% tolerance of 2x source
|
||||
expect(merged).toBeGreaterThan(srcDuration * 2 * 0.7);
|
||||
expect(merged).toBeLessThan(srcDuration * 2 * 1.3);
|
||||
|
||||
// Record which path was taken for the report
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`merge-videos happy path: ${path}`);
|
||||
}, 120_000);
|
||||
|
||||
it("rejects a single file", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "only.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/merge-videos",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// merge-videos has executionHint "long", so 202 is returned immediately.
|
||||
// The worker then fails with InputValidationError. Poll for the failure.
|
||||
expect(res.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(res.body);
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
const { db, schema } = await import("../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
let row: { status: string; error: unknown } | undefined;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
[row] = await db
|
||||
.select({ status: schema.jobs.status, error: schema.jobs.error })
|
||||
.from(schema.jobs)
|
||||
.where(eq(schema.jobs.id, jobId));
|
||||
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// Observed behavior: 202 (async) -> job status "failed" with error message.
|
||||
// The plan noted "400/422" but long-hint tools skip the sync window.
|
||||
expect(row?.status).toBe("failed");
|
||||
const error = row?.error as { message?: string } | null;
|
||||
expect(error?.message).toMatch(/at least two/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MediaInputHandler } from "../../apps/api/src/modality/media-input.js";
|
||||
|
||||
const FIX = join(__dirname, "..", "fixtures");
|
||||
const MP4 = readFileSync(join(FIX, "media", "tiny.mp4"));
|
||||
const MP3 = readFileSync(join(FIX, "media", "tiny.mp3"));
|
||||
const SRT = readFileSync(join(FIX, "media", "tiny.srt"));
|
||||
const PNG = readFileSync(join(FIX, "test-200x150.png"));
|
||||
|
||||
function scratch(): { scratchDir: string } {
|
||||
return { scratchDir: mkdtempSync(join(tmpdir(), "media-kinds-")) };
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("media input kinds", () => {
|
||||
it("kind subtitle accepts srt without ffprobe and rejects binaries", async () => {
|
||||
const h = new MediaInputHandler("subtitle");
|
||||
await expect(h.prepare(SRT, "s.srt", scratch())).resolves.toBeDefined();
|
||||
await expect(h.prepare(MP4, "v.srt", scratch())).rejects.toThrow(/subtitle/i);
|
||||
});
|
||||
it("kind image accepts a still image and rejects audio", async () => {
|
||||
const h = new MediaInputHandler("image");
|
||||
await expect(h.prepare(PNG, "p.png", scratch())).resolves.toBeDefined();
|
||||
await expect(h.prepare(MP3, "a.mp3", scratch())).rejects.toThrow(/image/i);
|
||||
});
|
||||
it("kind video still rejects audio-only and stills (regression)", async () => {
|
||||
const h = new MediaInputHandler("video");
|
||||
await expect(h.prepare(MP3, "a.mp3", scratch())).rejects.toThrow(/video/i);
|
||||
await expect(h.prepare(PNG, "p.png", scratch())).rejects.toThrow(/still image/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("replace-audio (requires ffmpeg)", () => {
|
||||
it("replaces video audio with the supplied audio track", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/replace-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// replace-audio is fast-hint, so expect 200 (sync window)
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: envelope.downloadUrl,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
// Probe the output: must have both video and audio streams
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "replace-audio-test-"));
|
||||
const probeFile = join(tmpDir, "replaced.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.streams.some((s) => s.type === "video")).toBe(true);
|
||||
expect(info.streams.some((s) => s.type === "audio")).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects when files are in wrong order (mp3 first, mp4 second)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/replace-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// The inputKinds seam validates input 0 as kind "video".
|
||||
// mp3 has no video stream, so the handler rejects before processV2.
|
||||
// InputValidationError defaults to 400.
|
||||
expect(res.statusCode).toBe(400);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.error).toMatch(/no video stream/i);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects when only one file is provided", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/replace-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
// Fast-hint tool: processV2 checks input count and throws,
|
||||
// caught by the factory's generic catch -> 422.
|
||||
expect(res.statusCode).toBe(422);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.details).toMatch(/video and an audio/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/resize-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("resize-video (requires ffmpeg)", () => {
|
||||
it("resizes to width 64 and returns 200", async () => {
|
||||
const res = await runTool({ width: 64 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "resize-test-"));
|
||||
const probeFile = join(tmpDir, "resized.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
expect(v?.width).toBe(64);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects custom preset with no dimensions", async () => {
|
||||
const res = await runTool({ preset: "custom" });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
let sourceDurationS: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "rev-src-"));
|
||||
const srcFile = join(tmpDir, "tiny.mp4");
|
||||
writeFileSync(srcFile, MP4);
|
||||
const info = await probeMedia(srcFile);
|
||||
sourceDurationS = info.durationS ?? 1;
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/reverse-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("reverse-video (requires ffmpeg)", () => {
|
||||
it("reverses a clip and returns 200 with similar duration", async () => {
|
||||
const res = await runTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "rev-test-"));
|
||||
const probeFile = join(tmpDir, "reversed.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.durationS).not.toBeNull();
|
||||
|
||||
const tolerance = sourceDurationS * 0.3;
|
||||
expect(info.durationS as number).toBeGreaterThan(sourceDurationS - tolerance);
|
||||
expect(info.durationS as number).toBeLessThan(sourceDurationS + tolerance);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/rotate-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("rotate-video (requires ffmpeg)", () => {
|
||||
it("rotates cw90 and swaps dimensions", async () => {
|
||||
// First probe the source to know its original dimensions
|
||||
const srcDir = mkdtempSync(join(tmpdir(), "rotate-src-"));
|
||||
const srcFile = join(srcDir, "tiny.mp4");
|
||||
writeFileSync(srcFile, MP4);
|
||||
const srcInfo = await probeMedia(srcFile);
|
||||
const srcV = srcInfo.streams.find((s) => s.type === "video");
|
||||
const srcW = srcV?.width ?? 0;
|
||||
const srcH = srcV?.height ?? 0;
|
||||
|
||||
const res = await runTool({ transform: "cw90" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "rotate-test-"));
|
||||
const probeFile = join(tmpDir, "rotated.mp4");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
// After 90-degree rotation, width and height should swap
|
||||
expect(v?.width).toBe(srcH);
|
||||
expect(v?.height).toBe(srcW);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown> = {}) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/stabilize-video",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles both the 200 sync-window path and the 202+poll path.
|
||||
* Long-hint tools may complete within the sync window (200) or go async (202).
|
||||
*/
|
||||
async function resolveResult(res: Awaited<ReturnType<typeof runTool>>): Promise<{
|
||||
path: "sync" | "poll";
|
||||
downloadPayload: Buffer;
|
||||
}> {
|
||||
if (res.statusCode === 200) {
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
return { path: "sync", downloadPayload: dl.rawPayload };
|
||||
}
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(res.body);
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
const { db, schema } = await import("../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
let row: { status: string; outputRefs: unknown } | undefined;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
|
||||
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
expect(row?.status).toBe("completed");
|
||||
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
return { path: "poll", downloadPayload: dl.rawPayload };
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("stabilize-video (requires ffmpeg)", () => {
|
||||
it("stabilizes a video and returns a downloadable mp4", async () => {
|
||||
const res = await runTool({});
|
||||
const { downloadPayload } = await resolveResult(res);
|
||||
expect(downloadPayload.length).toBeGreaterThan(100);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "stab-test-"));
|
||||
const probeFile = join(tmpDir, "stabilized.mp4");
|
||||
writeFileSync(probeFile, downloadPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const v = info.streams.find((s) => s.type === "video");
|
||||
expect(v).toBeDefined();
|
||||
}, 120_000);
|
||||
|
||||
it("rejects smoothing out of range with 400", async () => {
|
||||
const res = await runTool({ smoothing: 100 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
adminToken = await loginAsAdmin(testApp.app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/video-color",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("video-color (requires ffmpeg)", () => {
|
||||
it("desaturates (saturation 0) and returns 200", async () => {
|
||||
const res = await runTool({ saturation: 0 });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(100);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects brightness out of range with 400", async () => {
|
||||
const res = await runTool({ brightness: 5 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user