feat(tools): 2.0 phase 5 wave 3a - video depth (22 tools) (#221)

This commit is contained in:
SnapOtter
2026-06-13 10:19:00 +08:00
parent 2f39e38162
commit 5f98b48593
109 changed files with 12086 additions and 838 deletions
@@ -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": {}
}
}
+40 -11
View File
@@ -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 };
}
+51 -14
View File
@@ -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 };
}
}
+26 -1
View File
@@ -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,
});
+97
View File
@@ -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 };
},
});
}
+98
View File
@@ -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",
};
},
});
}
+46
View File
@@ -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 };
},
});
}
+68
View File
@@ -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",
};
},
});
}
+63
View File
@@ -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",
};
},
});
}
+46 -2
View File
@@ -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 },
+90
View File
@@ -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),
};
},
});
}
+71
View File
@@ -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 };
},
});
}
+54
View File
@@ -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 };
},
});
}
+49
View File
@@ -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,
},
},
};
},
});
}
+101
View File
@@ -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>
);
}
+24
View File
@@ -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,
+30 -1
View File
@@ -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",
]);
+151 -1
View File
@@ -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 }],
+18 -7
View File
@@ -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;
}),
};
}
+31
View File
@@ -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;
}
+1
View File
@@ -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";
+243 -1
View File
@@ -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",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+257
View File
@@ -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)",
+1
View File
@@ -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",
Binary file not shown.
+7
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
WEBVTT
00:00.000 --> 00:00.500
SnapOtter subtitle one
00:00.500 --> 00:01.000
Second line
+4
View File
@@ -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 {
+89
View File
@@ -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);
});
});
+84
View File
@@ -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);
});
+129
View File
@@ -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);
});
+64
View File
@@ -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);
});
+65
View File
@@ -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);
});
});
+68
View File
@@ -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);
});
+76
View File
@@ -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);
});
+79
View File
@@ -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);
});
+137
View File
@@ -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);
});
});
+97
View File
@@ -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);
});
+57
View File
@@ -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);
});
});
+63
View File
@@ -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);
});
+62
View File
@@ -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);
});
+91
View File
@@ -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);
});
});
+49
View File
@@ -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