mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 3b - audio depth (14 tools) (#222)
This commit is contained in:
@@ -1,54 +1,67 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
|
||||
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"api_keys": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'Default API Key'"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"last_used_at": {
|
||||
"name": "last_used_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -65,120 +78,423 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"jobs": {
|
||||
"name": "jobs",
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"actor_username": {
|
||||
"name": "actor_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'queued'"
|
||||
"notNull": true
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"input_files": {
|
||||
"name": "input_files",
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"output_path": {
|
||||
"name": "output_path",
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"audit_log_actor_id_users_id_fk": {
|
||||
"name": "audit_log_actor_id_users_id_fk",
|
||||
"tableFrom": "audit_log",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["actor_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"public.jobs": {
|
||||
"name": "jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"tool_id": {
|
||||
"name": "tool_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pool": {
|
||||
"name": "pool",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "job_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"default": 0
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"input_refs": {
|
||||
"name": "input_refs",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"output_refs": {
|
||||
"name": "output_refs",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_in": {
|
||||
"name": "bytes_in",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_out": {
|
||||
"name": "bytes_out",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"duration_ms": {
|
||||
"name": "duration_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"jobs_created_at_idx": {
|
||||
"name": "jobs_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"jobs_status_idx": {
|
||||
"name": "jobs_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"jobs_user_id_users_id_fk": {
|
||||
"name": "jobs_user_id_users_id_fk",
|
||||
"tableFrom": "jobs",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pipelines": {
|
||||
"name": "pipelines",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pipelines_user_id_users_id_fk": {
|
||||
"name": "pipelines_user_id_users_id_fk",
|
||||
"tableFrom": "pipelines",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.roles": {
|
||||
"name": "roles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"default": "''"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_builtin": {
|
||||
"name": "is_builtin",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"roles_created_by_users_id_fk": {
|
||||
"name": "roles_created_by_users_id_fk",
|
||||
"tableFrom": "roles",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["created_by"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"roles_name_unique": {
|
||||
"name": "roles_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -195,115 +511,297 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"settings": {
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"teams_name_unique": {
|
||||
"name": "teams_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_files": {
|
||||
"name": "user_files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_name": {
|
||||
"name": "original_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stored_name": {
|
||||
"name": "stored_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"default": 1
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tool_chain": {
|
||||
"name": "tool_chain",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_files_user_id_users_id_fk": {
|
||||
"name": "user_files_user_id_users_id_fk",
|
||||
"tableFrom": "user_files",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"team": {
|
||||
"name": "team",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Default'"
|
||||
},
|
||||
"must_change_password": {
|
||||
"name": "must_change_password",
|
||||
"type": "integer",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"auth_provider": {
|
||||
"name": "auth_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'local'"
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": ["username"],
|
||||
"isUnique": true
|
||||
"notNull": true
|
||||
},
|
||||
"analytics_enabled": {
|
||||
"name": "analytics_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_shown_at": {
|
||||
"name": "analytics_consent_shown_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_remind_at": {
|
||||
"name": "analytics_consent_remind_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["username"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
"enums": {
|
||||
"public.job_status": {
|
||||
"name": "job_status",
|
||||
"schema": "public",
|
||||
"values": ["queued", "processing", "completed", "failed", "canceled"]
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,54 @@
|
||||
{
|
||||
"id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90",
|
||||
"prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458",
|
||||
"prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key_prefix": {
|
||||
"name": "key_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'Default API Key'"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_used_at": {
|
||||
"name": "last_used_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -78,423 +65,165 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.audit_log": {
|
||||
"name": "audit_log",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"actor_username": {
|
||||
"name": "actor_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"audit_log_actor_id_users_id_fk": {
|
||||
"name": "audit_log_actor_id_users_id_fk",
|
||||
"tableFrom": "audit_log",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["actor_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.jobs": {
|
||||
"jobs": {
|
||||
"name": "jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tool_id": {
|
||||
"name": "tool_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pool": {
|
||||
"name": "pool",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "job_status",
|
||||
"typeSchema": "public",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "jsonb",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"input_refs": {
|
||||
"name": "input_refs",
|
||||
"type": "jsonb",
|
||||
"input_files": {
|
||||
"name": "input_files",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"output_refs": {
|
||||
"name": "output_refs",
|
||||
"type": "jsonb",
|
||||
"output_path": {
|
||||
"name": "output_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "jsonb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_in": {
|
||||
"name": "bytes_in",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"bytes_out": {
|
||||
"name": "bytes_out",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"duration_ms": {
|
||||
"name": "duration_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"jobs_created_at_idx": {
|
||||
"name": "jobs_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"jobs_status_idx": {
|
||||
"name": "jobs_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"jobs_user_id_users_id_fk": {
|
||||
"name": "jobs_user_id_users_id_fk",
|
||||
"tableFrom": "jobs",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.pipelines": {
|
||||
"pipelines": {
|
||||
"name": "pipelines",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"type": "jsonb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pipelines_user_id_users_id_fk": {
|
||||
"name": "pipelines_user_id_users_id_fk",
|
||||
"tableFrom": "pipelines",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.roles": {
|
||||
"name": "roles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_builtin": {
|
||||
"name": "is_builtin",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"roles_created_by_users_id_fk": {
|
||||
"name": "roles_created_by_users_id_fk",
|
||||
"tableFrom": "roles",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["created_by"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"roles_name_unique": {
|
||||
"name": "roles_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -511,297 +240,115 @@
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.settings": {
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"teams_name_unique": {
|
||||
"name": "teams_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["name"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_files": {
|
||||
"name": "user_files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_name": {
|
||||
"name": "original_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stored_name": {
|
||||
"name": "stored_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tool_chain": {
|
||||
"name": "tool_chain",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_files_user_id_users_id_fk": {
|
||||
"name": "user_files_user_id_users_id_fk",
|
||||
"tableFrom": "user_files",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"public.users": {
|
||||
"users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"team": {
|
||||
"name": "team",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Default'"
|
||||
},
|
||||
"must_change_password": {
|
||||
"name": "must_change_password",
|
||||
"type": "boolean",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"auth_provider": {
|
||||
"name": "auth_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'local'"
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"analytics_enabled": {
|
||||
"name": "analytics_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_shown_at": {
|
||||
"name": "analytics_consent_shown_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"analytics_consent_remind_at": {
|
||||
"name": "analytics_consent_remind_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["username"]
|
||||
"columns": ["username"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.job_status": {
|
||||
"name": "job_status",
|
||||
"schema": "public",
|
||||
"values": ["queued", "processing", "completed", "failed", "canceled"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,55 @@ export function audioContentType(ext: string): string {
|
||||
return EXT_AUDIO_CONTENT_TYPES[ext.toLowerCase()] || "audio/mpeg";
|
||||
}
|
||||
|
||||
/** 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 interface AudioOutput {
|
||||
ext: string;
|
||||
contentType: string;
|
||||
encodeArgs: string[];
|
||||
}
|
||||
|
||||
const AUDIO_OUTPUTS: Record<string, AudioOutput> = {
|
||||
".mp3": {
|
||||
ext: ".mp3",
|
||||
contentType: "audio/mpeg",
|
||||
encodeArgs: ["-c:a", "libmp3lame", "-b:a", "192k"],
|
||||
},
|
||||
".wav": { ext: ".wav", contentType: "audio/wav", encodeArgs: ["-c:a", "pcm_s16le"] },
|
||||
".ogg": {
|
||||
ext: ".ogg",
|
||||
contentType: "audio/ogg",
|
||||
encodeArgs: ["-c:a", "libvorbis", "-b:a", "192k"],
|
||||
},
|
||||
".opus": {
|
||||
ext: ".opus",
|
||||
contentType: "audio/opus",
|
||||
encodeArgs: ["-c:a", "libopus", "-b:a", "128k"],
|
||||
},
|
||||
".flac": { ext: ".flac", contentType: "audio/flac", encodeArgs: ["-c:a", "flac"] },
|
||||
".m4a": { ext: ".m4a", contentType: "audio/mp4", encodeArgs: ["-c:a", "aac", "-b:a", "192k"] },
|
||||
".aac": { ext: ".m4a", contentType: "audio/mp4", encodeArgs: ["-c:a", "aac", "-b:a", "192k"] },
|
||||
".aiff": { ext: ".aiff", contentType: "audio/aiff", encodeArgs: ["-c:a", "pcm_s16be"] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Encoder selection for filtered (re-encoded) audio outputs, keyed by the
|
||||
* SOURCE extension. Decode-only sources (wma/amr/ape/...) fall back to mp3.
|
||||
*/
|
||||
export function audioOutputFor(srcExt: string): AudioOutput {
|
||||
return AUDIO_OUTPUTS[srcExt.toLowerCase()] ?? AUDIO_OUTPUTS[".mp3"];
|
||||
}
|
||||
|
||||
export interface MediaRunResult {
|
||||
outPath: string;
|
||||
durationS: number | null;
|
||||
@@ -57,9 +106,9 @@ export async function runFfmpegWithProgress(
|
||||
args: string[],
|
||||
durationS: number | null,
|
||||
opts: { timeoutMs?: number } = {},
|
||||
): Promise<void> {
|
||||
): Promise<string> {
|
||||
ctx.report(5, "Preparing");
|
||||
await runFfmpeg(args, {
|
||||
return runFfmpeg(args, {
|
||||
signal: ctx.signal,
|
||||
timeoutMs: opts.timeoutMs ?? 30 * 60_000,
|
||||
onProgress: (p) => {
|
||||
|
||||
@@ -209,12 +209,15 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
let clientJobId: string | null = null;
|
||||
let fileCount = 0;
|
||||
const received: ReceivedUpload[] = [];
|
||||
// Track last part for post-loop field recovery
|
||||
let lastPart: { fields?: Record<string, unknown> } | undefined;
|
||||
|
||||
// Parse multipart parts (file parts stream to object storage)
|
||||
try {
|
||||
const parts = request.parts();
|
||||
|
||||
for await (const part of parts) {
|
||||
lastPart = part as { fields?: Record<string, unknown> };
|
||||
if (part.type === "file") {
|
||||
fileCount++;
|
||||
if (fileCount > maxInputs) {
|
||||
@@ -248,6 +251,31 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The upstream parts iterator can terminate before yielding trailing
|
||||
// fields, but busboy has already populated part.fields on every part.
|
||||
if (lastPart?.fields) {
|
||||
const recover = (name: string): string | null => {
|
||||
const f = lastPart?.fields?.[name];
|
||||
const entry = Array.isArray(f) ? f[0] : f;
|
||||
if (entry != null && typeof (entry as { value?: unknown }).value === "string") {
|
||||
return (entry as { value: string }).value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (settingsRaw === null) {
|
||||
settingsRaw = recover("settings");
|
||||
}
|
||||
if (fileId === null) {
|
||||
fileId = recover("fileId");
|
||||
}
|
||||
if (clientJobId === null) {
|
||||
const raw = recover("clientJobId");
|
||||
if (raw !== null && raw.length > 0 && raw.length <= 128) {
|
||||
clientJobId = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runFfmpegWithProgress, 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(["stereo-to-mono", "mono-to-stereo", "swap"]),
|
||||
});
|
||||
|
||||
export function registerAudioChannels(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "audio-channels",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("audio-channels is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_channels${out.ext}`;
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const ch = info.streams.find((s) => s.type === "audio")?.channels ?? 0;
|
||||
|
||||
if ((settings.mode === "stereo-to-mono" || settings.mode === "swap") && ch < 2) {
|
||||
throw new InputValidationError("This mode needs a stereo input", 422);
|
||||
}
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
let args: string[];
|
||||
|
||||
if (settings.mode === "stereo-to-mono") {
|
||||
args = ["-i", inPath, "-ac", "1", ...out.encodeArgs, outPath];
|
||||
} else if (settings.mode === "mono-to-stereo") {
|
||||
args = ["-i", inPath, "-ac", "2", ...out.encodeArgs, outPath];
|
||||
} else {
|
||||
// swap
|
||||
args = ["-i", inPath, "-af", "pan=stereo|c0=c1|c1=c0", ...out.encodeArgs, outPath];
|
||||
}
|
||||
|
||||
await runFfmpegWithProgress(ctx, args, info.durationS);
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioContentType, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
strip: z.boolean().default(false),
|
||||
title: z.string().max(500).optional(),
|
||||
artist: z.string().max(500).optional(),
|
||||
album: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export function registerAudioMetadata(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "audio-metadata",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("audio-metadata is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const srcExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_metadata${srcExt}`;
|
||||
const contentType = audioContentType(srcExt);
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
|
||||
const args = ["-i", inPath];
|
||||
if (settings.strip) {
|
||||
args.push("-map_metadata", "-1");
|
||||
}
|
||||
for (const [key, value] of [
|
||||
["title", settings.title],
|
||||
["artist", settings.artist],
|
||||
["album", settings.album],
|
||||
] as const) {
|
||||
if (value !== undefined) {
|
||||
args.push("-metadata", `${key}=${value}`);
|
||||
}
|
||||
}
|
||||
args.push("-c", "copy");
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
args.push(outPath);
|
||||
|
||||
await runFfmpegWithProgress(ctx, args, info.durationS);
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: outName,
|
||||
contentType,
|
||||
resultPayload: {
|
||||
metadata: {
|
||||
container: info.container,
|
||||
durationS: info.durationS,
|
||||
bitrateKbps: info.bitrateKbps,
|
||||
tags: info.tags ?? {},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, buildAtempoChain, runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
factor: z.number().min(0.25).max(4).default(1.5),
|
||||
});
|
||||
|
||||
export function registerAudioSpeed(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "audio-speed",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("audio-speed is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_speed${out.ext}`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return ["-i", inPath, "-af", buildAtempoChain(settings.factor), ...out.encodeArgs, outPath];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
fadeInS: z.number().min(0).max(30).default(1),
|
||||
fadeOutS: z.number().min(0).max(30).default(1),
|
||||
})
|
||||
.refine((s) => s.fadeInS > 0 || s.fadeOutS > 0, {
|
||||
message: "Set a fade-in or fade-out",
|
||||
});
|
||||
|
||||
export function registerFadeAudio(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "fade-audio",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("fade-audio is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_fade${out.ext}`;
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const d = info.durationS ?? 0;
|
||||
|
||||
if ((d === 0 || info.durationS === null) && settings.fadeOutS > 0) {
|
||||
throw new InputValidationError("Could not determine audio duration for fade-out");
|
||||
}
|
||||
|
||||
const fin = Math.min(settings.fadeInS, d);
|
||||
const fout = Math.min(settings.fadeOutS, d);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (fin > 0) {
|
||||
parts.push(`afade=t=in:st=0:d=${fin}`);
|
||||
}
|
||||
if (fout > 0) {
|
||||
parts.push(`afade=t=out:st=${Math.max(0, d - fout)}:d=${fout}`);
|
||||
}
|
||||
const chain = parts.join(",");
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
["-i", inPath, "-af", chain, ...out.encodeArgs, outPath],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,9 @@ 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 { registerAudioChannels } from "./audio-channels.js";
|
||||
import { registerAudioMetadata } from "./audio-metadata.js";
|
||||
import { registerAudioSpeed } from "./audio-speed.js";
|
||||
import { registerBarcodeRead } from "./barcode-read.js";
|
||||
import { registerBeautify } from "./beautify.js";
|
||||
import { registerBlurFaces } from "./blur-faces.js";
|
||||
@@ -39,6 +42,7 @@ 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 { registerFadeAudio } from "./fade-audio.js";
|
||||
import { registerFavicon } from "./favicon.js";
|
||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
import { registerFlattenPdf } from "./flatten-pdf.js";
|
||||
@@ -56,10 +60,13 @@ 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 { registerMergeAudio } from "./merge-audio.js";
|
||||
import { registerMergePdf } from "./merge-pdf.js";
|
||||
import { registerMergeVideos } from "./merge-videos.js";
|
||||
import { registerMuteVideo } from "./mute-video.js";
|
||||
import { registerNoiseReduction } from "./noise-reduction.js";
|
||||
import { registerNoiseRemoval } from "./noise-removal.js";
|
||||
import { registerNormalizeAudio } from "./normalize-audio.js";
|
||||
import { registerNupPdf } from "./nup-pdf.js";
|
||||
import { registerOcr } from "./ocr.js";
|
||||
import { registerOptimizeForWeb } from "./optimize-for-web.js";
|
||||
@@ -71,6 +78,7 @@ import { registerPdfToImage } from "./pdf-to-image.js";
|
||||
import { registerPdfToText } from "./pdf-to-text.js";
|
||||
import { registerPdfToWord } from "./pdf-to-word.js";
|
||||
import { registerPdfaConvert } from "./pdfa-convert.js";
|
||||
import { registerPitchShift } from "./pitch-shift.js";
|
||||
import { registerProtectPdf } from "./protect-pdf.js";
|
||||
import { registerQrGenerate } from "./qr-generate.js";
|
||||
import { registerRedEyeRemoval } from "./red-eye-removal.js";
|
||||
@@ -83,13 +91,17 @@ import { registerReplaceColor } from "./replace-color.js";
|
||||
import { registerResize } from "./resize.js";
|
||||
import { registerResizeVideo } from "./resize-video.js";
|
||||
import { registerRestorePhoto } from "./restore-photo.js";
|
||||
import { registerReverseAudio } from "./reverse-audio.js";
|
||||
import { registerReverseVideo } from "./reverse-video.js";
|
||||
import { registerRingtoneMaker } from "./ringtone-maker.js";
|
||||
import { registerRotate } from "./rotate.js";
|
||||
import { registerRotatePdf } from "./rotate-pdf.js";
|
||||
import { registerRotateVideo } from "./rotate-video.js";
|
||||
import { registerSharpening } from "./sharpening.js";
|
||||
import { registerSilenceRemoval } from "./silence-removal.js";
|
||||
import { registerSmartCrop } from "./smart-crop.js";
|
||||
import { registerSplit } from "./split.js";
|
||||
import { registerSplitAudio } from "./split-audio.js";
|
||||
import { registerSplitCsv } from "./split-csv.js";
|
||||
import { registerSplitPdf } from "./split-pdf.js";
|
||||
import { registerStabilizeVideo } from "./stabilize-video.js";
|
||||
@@ -110,10 +122,12 @@ 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 { registerVolumeAdjust } from "./volume-adjust.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 { registerWaveformImage } from "./waveform-image.js";
|
||||
import { registerWordToPdf } from "./word-to-pdf.js";
|
||||
|
||||
/**
|
||||
@@ -229,9 +243,23 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "watermark-video", register: registerWatermarkVideo },
|
||||
|
||||
// Audio
|
||||
{ id: "audio-channels", register: registerAudioChannels },
|
||||
{ id: "audio-metadata", register: registerAudioMetadata },
|
||||
{ id: "audio-speed", register: registerAudioSpeed },
|
||||
{ id: "convert-audio", register: registerConvertAudio },
|
||||
{ id: "trim-audio", register: registerTrimAudio },
|
||||
{ id: "extract-audio", register: registerExtractAudio },
|
||||
{ id: "fade-audio", register: registerFadeAudio },
|
||||
{ id: "merge-audio", register: registerMergeAudio },
|
||||
{ id: "noise-reduction", register: registerNoiseReduction },
|
||||
{ id: "normalize-audio", register: registerNormalizeAudio },
|
||||
{ id: "pitch-shift", register: registerPitchShift },
|
||||
{ id: "reverse-audio", register: registerReverseAudio },
|
||||
{ id: "ringtone-maker", register: registerRingtoneMaker },
|
||||
{ id: "silence-removal", register: registerSilenceRemoval },
|
||||
{ id: "split-audio", register: registerSplitAudio },
|
||||
{ id: "trim-audio", register: registerTrimAudio },
|
||||
{ id: "volume-adjust", register: registerVolumeAdjust },
|
||||
{ id: "waveform-image", register: registerWaveformImage },
|
||||
|
||||
// PDF & Documents
|
||||
{ id: "merge-pdf", register: registerMergePdf },
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
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({
|
||||
format: z.enum(["mp3", "wav", "flac", "m4a"]).default("mp3"),
|
||||
});
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
flac: "audio/flac",
|
||||
m4a: "audio/mp4",
|
||||
};
|
||||
|
||||
const ENCODERS: Record<string, string[]> = {
|
||||
mp3: ["-c:a", "libmp3lame", "-b:a", "192k"],
|
||||
wav: ["-c:a", "pcm_s16le"],
|
||||
flac: ["-c:a", "flac"],
|
||||
m4a: ["-c:a", "aac", "-b:a", "192k"],
|
||||
};
|
||||
|
||||
export function registerMergeAudio(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "merge-audio",
|
||||
maxInputs: 10,
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("merge-audio is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length < 2) {
|
||||
throw new InputValidationError("Merging needs at least two audio files");
|
||||
}
|
||||
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const paths = await stageMediaInputs(ctx);
|
||||
|
||||
// Probe each for total duration (progress mapping)
|
||||
let totalS = 0;
|
||||
for (const p of paths) {
|
||||
const info = await probeMedia(p);
|
||||
totalS += info.durationS ?? 0;
|
||||
}
|
||||
|
||||
// Build per-input normalize chains + audio-only concat
|
||||
const parts: string[] = [];
|
||||
const refs: string[] = [];
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
parts.push(
|
||||
`[${i}:a]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo[a${i}]`,
|
||||
);
|
||||
refs.push(`[a${i}]`);
|
||||
}
|
||||
const filter = `${parts.join(";")};\n${refs.join("")}concat=n=${paths.length}:v=0:a=1[a]`;
|
||||
|
||||
const dir = join(ctx.scratchDir, "media");
|
||||
await mkdir(dir, { recursive: true });
|
||||
const outPath = join(dir, `merged.${settings.format}`);
|
||||
|
||||
const args = [
|
||||
...paths.flatMap((p) => ["-i", p]),
|
||||
"-filter_complex",
|
||||
filter,
|
||||
"-map",
|
||||
"[a]",
|
||||
...ENCODERS[settings.format],
|
||||
outPath,
|
||||
];
|
||||
|
||||
await runFfmpegWithProgress(ctx, args, totalS || null);
|
||||
|
||||
return {
|
||||
scratchPath: outPath,
|
||||
filename: `merged.${settings.format}`,
|
||||
contentType: CONTENT_TYPES[settings.format],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const NR_MAP: Record<string, number> = {
|
||||
light: 6,
|
||||
medium: 12,
|
||||
strong: 24,
|
||||
};
|
||||
|
||||
const settingsSchema = z.object({
|
||||
strength: z.enum(["light", "medium", "strong"]).default("medium"),
|
||||
});
|
||||
|
||||
export function registerNoiseReduction(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "noise-reduction",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("noise-reduction is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_denoised${out.ext}`;
|
||||
|
||||
const nr = NR_MAP[settings.strength];
|
||||
const chain = `afftdn=nr=${nr}:nf=-50`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return ["-i", inPath, "-af", chain, ...out.encodeArgs, outPath];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerNormalizeAudio(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "normalize-audio",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("normalize-audio is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_normalized${out.ext}`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return ["-i", inPath, "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", ...out.encodeArgs, outPath];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
semitones: z.number().int().min(-12).max(12).default(3),
|
||||
})
|
||||
.refine((s) => s.semitones !== 0, { message: "Semitones must be nonzero" });
|
||||
|
||||
export function registerPitchShift(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "pitch-shift",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("pitch-shift is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_pitch${out.ext}`;
|
||||
|
||||
const ratio = (2 ** (settings.semitones / 12)).toFixed(6);
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return ["-i", inPath, "-af", `rubberband=pitch=${ratio}`, ...out.encodeArgs, outPath];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerReverseAudio(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "reverse-audio",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("reverse-audio is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_reversed${out.ext}`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return ["-i", inPath, "-af", "areverse", ...out.encodeArgs, outPath];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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({
|
||||
startS: z.number().min(0).default(0),
|
||||
durationS: z.number().min(1).max(30).default(30),
|
||||
});
|
||||
|
||||
export function registerRingtoneMaker(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "ringtone-maker",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("ringtone-maker is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}.m4r`;
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
|
||||
if (settings.startS >= (info.durationS ?? Infinity)) {
|
||||
throw new InputValidationError("Start is beyond the end of the audio");
|
||||
}
|
||||
|
||||
const outPath = join(ctx.scratchDir, "media", outName);
|
||||
await runFfmpegWithProgress(
|
||||
ctx,
|
||||
[
|
||||
"-ss",
|
||||
String(settings.startS),
|
||||
"-t",
|
||||
String(settings.durationS),
|
||||
"-i",
|
||||
inPath,
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-f",
|
||||
"ipod",
|
||||
outPath,
|
||||
],
|
||||
info.durationS,
|
||||
);
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType: "audio/mp4" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
thresholdDb: z.number().min(-80).max(-20).default(-50),
|
||||
minSilenceS: z.number().min(0.1).max(5).default(0.5),
|
||||
});
|
||||
|
||||
export function registerSilenceRemoval(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "silence-removal",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("silence-removal is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_nosilence${out.ext}`;
|
||||
|
||||
const t = settings.thresholdDb;
|
||||
const d = settings.minSilenceS;
|
||||
const chain =
|
||||
`silenceremove=start_periods=1:start_threshold=${t}dB:start_silence=0.1` +
|
||||
`:stop_periods=-1:stop_threshold=${t}dB:stop_duration=${d}`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return ["-i", inPath, "-af", chain, ...out.encodeArgs, outPath];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, readdir } from "node:fs/promises";
|
||||
import { extname, 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(["time", "parts", "silence"]).default("time"),
|
||||
segmentS: z.number().min(1).max(3600).default(60),
|
||||
parts: z.number().int().min(2).max(20).default(2),
|
||||
thresholdDb: z.number().min(-80).max(-20).default(-40),
|
||||
minSilenceS: z.number().min(0.1).max(10).default(0.3),
|
||||
});
|
||||
|
||||
export function registerSplitAudio(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "split-audio",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("split-audio is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const srcExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
|
||||
const [inPath] = await stageMediaInputs(ctx);
|
||||
const info = await probeMedia(inPath);
|
||||
const duration = info.durationS;
|
||||
|
||||
const framesDir = join(ctx.scratchDir, "media", "parts");
|
||||
await mkdir(framesDir, { recursive: true });
|
||||
|
||||
if (settings.mode === "time" || settings.mode === "parts") {
|
||||
let segLen: number;
|
||||
if (settings.mode === "parts") {
|
||||
if (duration === null || duration === undefined) {
|
||||
throw new InputValidationError("Could not determine audio duration");
|
||||
}
|
||||
segLen = Math.max(0.1, duration / settings.parts + 0.001);
|
||||
} else {
|
||||
segLen = settings.segmentS;
|
||||
}
|
||||
|
||||
ctx.report(5, "Splitting audio");
|
||||
await runFfmpeg(
|
||||
[
|
||||
"-i",
|
||||
inPath,
|
||||
"-f",
|
||||
"segment",
|
||||
"-segment_time",
|
||||
String(segLen),
|
||||
"-c",
|
||||
"copy",
|
||||
join(framesDir, `part-%03d${srcExt}`),
|
||||
],
|
||||
{ signal: ctx.signal, timeoutMs: 10 * 60_000 },
|
||||
);
|
||||
} else {
|
||||
// silence mode: pass 1 - detect silence regions
|
||||
ctx.report(5, "Detecting silence");
|
||||
const nullOut = join(ctx.scratchDir, "media", "null.out");
|
||||
const stderr = await runFfmpeg(
|
||||
[
|
||||
"-i",
|
||||
inPath,
|
||||
"-af",
|
||||
`silencedetect=noise=${settings.thresholdDb}dB:d=${settings.minSilenceS}`,
|
||||
"-f",
|
||||
"null",
|
||||
nullOut,
|
||||
],
|
||||
{ signal: ctx.signal, timeoutMs: 10 * 60_000 },
|
||||
);
|
||||
|
||||
// Parse silence_start / silence_end pairs and compute midpoints
|
||||
const starts = [...stderr.matchAll(/silence_start:\s*([\d.]+)/g)].map((m) => Number(m[1]));
|
||||
const ends = [...stderr.matchAll(/silence_end:\s*([\d.]+)/g)].map((m) => Number(m[1]));
|
||||
const cuts: number[] = [];
|
||||
for (let i = 0; i < Math.min(starts.length, ends.length); i++) {
|
||||
cuts.push((starts[i] + ends[i]) / 2);
|
||||
}
|
||||
if (cuts.length === 0) {
|
||||
throw new InputValidationError("No silence found to split at");
|
||||
}
|
||||
|
||||
// Build segment boundaries
|
||||
const d = duration ?? 0;
|
||||
const boundaries = [0, ...cuts, d];
|
||||
const total = boundaries.length - 1;
|
||||
|
||||
for (let seg = 0; seg < total; seg++) {
|
||||
const from = boundaries[seg];
|
||||
const to = boundaries[seg + 1];
|
||||
const partPath = join(framesDir, `part-${String(seg).padStart(3, "0")}${srcExt}`);
|
||||
ctx.report(
|
||||
Math.min(90, 10 + Math.round(((seg + 1) / total) * 80)),
|
||||
`Extracting segment ${seg + 1}/${total}`,
|
||||
);
|
||||
await runFfmpeg(
|
||||
["-ss", String(from), "-to", String(to), "-i", inPath, "-c", "copy", partPath],
|
||||
{
|
||||
signal: ctx.signal,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect produced parts
|
||||
const files = (await readdir(framesDir)).filter((f) => f.endsWith(srcExt));
|
||||
files.sort();
|
||||
if (files.length < 2) {
|
||||
throw new InputValidationError("Nothing to split: output would be a single segment");
|
||||
}
|
||||
|
||||
// Zip parts (same pattern as video-to-frames)
|
||||
ctx.report(92, "Creating archive");
|
||||
const zipPath = join(ctx.scratchDir, "media", `${base}_parts.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}_parts.zip`,
|
||||
contentType: "application/zip",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,12 @@ 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 {
|
||||
buildAtempoChain,
|
||||
runFfmpegWithProgress,
|
||||
stageMediaInputs,
|
||||
videoContentType,
|
||||
} from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -10,18 +15,6 @@ const settingsSchema = z.object({
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { audioOutputFor, runMediaTool } from "../../lib/media-tool.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
gainDb: z.number().min(-30).max(30).default(3),
|
||||
});
|
||||
|
||||
export function registerVolumeAdjust(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "volume-adjust",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("volume-adjust is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const origExt = extname(ctx.inputs[0].filename) || ".mp3";
|
||||
const out = audioOutputFor(origExt);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_volume${out.ext}`;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return ["-i", inPath, "-af", `volume=${settings.gainDb}dB`, ...out.encodeArgs, outPath];
|
||||
});
|
||||
return { scratchPath: outPath, filename: outName, contentType: out.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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({
|
||||
width: z.number().int().min(256).max(3840).default(1024),
|
||||
height: z.number().int().min(64).max(1080).default(256),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#4f46e5"),
|
||||
});
|
||||
|
||||
export function registerWaveformImage(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "waveform-image",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("waveform-image is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, "");
|
||||
const outName = `${base}_waveform.png`;
|
||||
|
||||
const c = settings.color.replace("#", "0x");
|
||||
const w = settings.width;
|
||||
const h = settings.height;
|
||||
|
||||
const { outPath } = await runMediaTool(ctx, outName, (inPath, outPath) => {
|
||||
return [
|
||||
"-i",
|
||||
inPath,
|
||||
"-filter_complex",
|
||||
`showwavespic=s=${w}x${h}:colors=${c}`,
|
||||
"-frames:v",
|
||||
"1",
|
||||
outPath,
|
||||
];
|
||||
});
|
||||
|
||||
return { scratchPath: outPath, filename: outName, contentType: "image/png" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
type ChannelMode = "stereo-to-mono" | "mono-to-stereo" | "swap";
|
||||
|
||||
export function AudioChannelsSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["audio-channels"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("audio-channels");
|
||||
|
||||
const [mode, setMode] = useState<ChannelMode>("mono-to-stereo");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { mode };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ac-mode" className="text-xs text-muted-foreground">
|
||||
{s.mode}
|
||||
</label>
|
||||
<select
|
||||
id="ac-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as ChannelMode)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="stereo-to-mono">{s["stereo-to-mono"]}</option>
|
||||
<option value="mono-to-stereo">{s["mono-to-stereo"]}</option>
|
||||
<option value="swap">{s.swap}</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="audio-channels-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,113 @@
|
||||
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 AudioMetadataSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["audio-metadata"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("audio-metadata");
|
||||
|
||||
const [strip, setStrip] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [artist, setArtist] = useState("");
|
||||
const [album, setAlbum] = useState("");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { strip };
|
||||
if (title) settings.title = title;
|
||||
if (artist) settings.artist = artist;
|
||||
if (album) settings.album = album;
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={strip}
|
||||
onChange={(e) => setStrip(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{s.strip}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label htmlFor="am-title" className="text-xs text-muted-foreground">
|
||||
{s.title}
|
||||
</label>
|
||||
<input
|
||||
id="am-title"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(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="am-artist" className="text-xs text-muted-foreground">
|
||||
{s.artist}
|
||||
</label>
|
||||
<input
|
||||
id="am-artist"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={artist}
|
||||
onChange={(e) => setArtist(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="am-album" className="text-xs text-muted-foreground">
|
||||
{s.album}
|
||||
</label>
|
||||
<input
|
||||
id="am-album"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={album}
|
||||
onChange={(e) => setAlbum(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="audio-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,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 AudioSpeedSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["audio-speed"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("audio-speed");
|
||||
|
||||
const [factor, setFactor] = useState(1.5);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { factor };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="as-factor" className="text-xs text-muted-foreground">
|
||||
{s.factor}
|
||||
</label>
|
||||
<input
|
||||
id="as-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>
|
||||
|
||||
{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="audio-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,88 @@
|
||||
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 FadeAudioSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["fade-audio"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("fade-audio");
|
||||
|
||||
const [fadeInS, setFadeInS] = useState(1);
|
||||
const [fadeOutS, setFadeOutS] = useState(1);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { fadeInS, fadeOutS };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="fa-fadein" className="text-xs text-muted-foreground">
|
||||
{s["fade-in-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="fa-fadein"
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
step={0.5}
|
||||
value={fadeInS}
|
||||
onChange={(e) => setFadeInS(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="fa-fadeout" className="text-xs text-muted-foreground">
|
||||
{s["fade-out-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="fa-fadeout"
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
step={0.5}
|
||||
value={fadeOutS}
|
||||
onChange={(e) => setFadeOutS(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="fade-audio-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,76 @@
|
||||
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 AudioFormat = "mp3" | "wav" | "flac" | "m4a";
|
||||
|
||||
export function MergeAudioSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["merge-audio"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("merge-audio");
|
||||
|
||||
const [outFormat, setOutFormat] = useState<AudioFormat>("mp3");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { format: outFormat };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ma-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="ma-format"
|
||||
value={outFormat}
|
||||
onChange={(e) => setOutFormat(e.target.value as AudioFormat)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="mp3">{s.mp3}</option>
|
||||
<option value="wav">{s.wav}</option>
|
||||
<option value="flac">{s.flac}</option>
|
||||
<option value="m4a">{s.m4a}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">{s["order-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-audio-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";
|
||||
|
||||
type Strength = "light" | "medium" | "strong";
|
||||
|
||||
export function NoiseReductionSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["noise-reduction"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("noise-reduction");
|
||||
|
||||
const [strength, setStrength] = useState<Strength>("medium");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { strength };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="nr-strength" className="text-xs text-muted-foreground">
|
||||
{s.strength}
|
||||
</label>
|
||||
<select
|
||||
id="nr-strength"
|
||||
value={strength}
|
||||
onChange={(e) => setStrength(e.target.value as Strength)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="light">{s.light}</option>
|
||||
<option value="medium">{s.medium}</option>
|
||||
<option value="strong">{s.strong}</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="noise-reduction-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 NormalizeAudioSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["normalize-audio"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("normalize-audio");
|
||||
|
||||
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="normalize-audio-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";
|
||||
|
||||
export function PitchShiftSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["pitch-shift"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("pitch-shift");
|
||||
|
||||
const [semitones, setSemitones] = useState(3);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { semitones };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ps-semi" className="text-xs text-muted-foreground">
|
||||
{s.semitones}
|
||||
</label>
|
||||
<input
|
||||
id="ps-semi"
|
||||
type="number"
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
value={semitones}
|
||||
onChange={(e) => setSemitones(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"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.hint}</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="pitch-shift-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,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 ReverseAudioSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["reverse-audio"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("reverse-audio");
|
||||
|
||||
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="reverse-audio-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,88 @@
|
||||
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 RingtoneMakerSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["ringtone-maker"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("ringtone-maker");
|
||||
|
||||
const [startS, setStartS] = useState(0);
|
||||
const [durationS, setDurationS] = useState(30);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { startS, durationS };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rm-start" className="text-xs text-muted-foreground">
|
||||
{s["start-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="rm-start"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={startS}
|
||||
onChange={(e) => setStartS(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="rm-duration" className="text-xs text-muted-foreground">
|
||||
{s["duration-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="rm-duration"
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
step={0.5}
|
||||
value={durationS}
|
||||
onChange={(e) => setDurationS(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"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s["max-hint"]}</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="ringtone-maker-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,88 @@
|
||||
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 SilenceRemovalSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["silence-removal"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("silence-removal");
|
||||
|
||||
const [thresholdDb, setThresholdDb] = useState(-50);
|
||||
const [minSilenceS, setMinSilenceS] = useState(0.5);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { thresholdDb, minSilenceS };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="sr-threshold" className="text-xs text-muted-foreground">
|
||||
{s["threshold-db"]}
|
||||
</label>
|
||||
<input
|
||||
id="sr-threshold"
|
||||
type="number"
|
||||
min={-80}
|
||||
max={-20}
|
||||
step={1}
|
||||
value={thresholdDb}
|
||||
onChange={(e) => setThresholdDb(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="sr-minsilence" className="text-xs text-muted-foreground">
|
||||
{s["min-silence-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="sr-minsilence"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
value={minSilenceS}
|
||||
onChange={(e) => setMinSilenceS(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="silence-removal-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,155 @@
|
||||
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 SplitMode = "time" | "parts" | "silence";
|
||||
|
||||
export function SplitAudioSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["split-audio"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("split-audio");
|
||||
|
||||
const [mode, setMode] = useState<SplitMode>("time");
|
||||
const [segmentS, setSegmentS] = useState(60);
|
||||
const [parts, setParts] = useState(2);
|
||||
const [thresholdDb, setThresholdDb] = useState(-40);
|
||||
const [minSilenceS, setMinSilenceS] = useState(0.3);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { mode };
|
||||
if (mode === "time") settings.segmentS = segmentS;
|
||||
if (mode === "parts") settings.parts = parts;
|
||||
if (mode === "silence") {
|
||||
settings.thresholdDb = thresholdDb;
|
||||
settings.minSilenceS = minSilenceS;
|
||||
}
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="sa-mode" className="text-xs text-muted-foreground">
|
||||
{s.mode}
|
||||
</label>
|
||||
<select
|
||||
id="sa-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as SplitMode)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="time">{s.time}</option>
|
||||
<option value="parts">{s.parts}</option>
|
||||
<option value="silence">{s.silence}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{mode === "time" && (
|
||||
<div>
|
||||
<label htmlFor="sa-segment" className="text-xs text-muted-foreground">
|
||||
{s["segment-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="sa-segment"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3600}
|
||||
step={1}
|
||||
value={segmentS}
|
||||
onChange={(e) => setSegmentS(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 === "parts" && (
|
||||
<div>
|
||||
<label htmlFor="sa-parts" className="text-xs text-muted-foreground">
|
||||
{s["num-parts"]}
|
||||
</label>
|
||||
<input
|
||||
id="sa-parts"
|
||||
type="number"
|
||||
min={2}
|
||||
max={20}
|
||||
step={1}
|
||||
value={parts}
|
||||
onChange={(e) => setParts(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 === "silence" && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="sa-threshold" className="text-xs text-muted-foreground">
|
||||
{s["threshold-db"]}
|
||||
</label>
|
||||
<input
|
||||
id="sa-threshold"
|
||||
type="number"
|
||||
min={-80}
|
||||
max={-20}
|
||||
step={1}
|
||||
value={thresholdDb}
|
||||
onChange={(e) => setThresholdDb(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="sa-minsilence" className="text-xs text-muted-foreground">
|
||||
{s["min-silence-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="sa-minsilence"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={minSilenceS}
|
||||
onChange={(e) => setMinSilenceS(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="split-audio-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,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 VolumeAdjustSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["volume-adjust"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("volume-adjust");
|
||||
|
||||
const [gainDb, setGainDb] = useState(3);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { gainDb };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="va-gain" className="text-xs text-muted-foreground">
|
||||
{s["gain-db"]}
|
||||
</label>
|
||||
<input
|
||||
id="va-gain"
|
||||
type="number"
|
||||
min={-30}
|
||||
max={30}
|
||||
step={0.5}
|
||||
value={gainDb}
|
||||
onChange={(e) => setGainDb(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="volume-adjust-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,103 @@
|
||||
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 WaveformImageSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["waveform-image"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("waveform-image");
|
||||
|
||||
const [width, setWidth] = useState(1024);
|
||||
const [height, setHeight] = useState(256);
|
||||
const [color, setColor] = useState("#4f46e5");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { width, height, color };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="wi-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="wi-width"
|
||||
type="number"
|
||||
min={256}
|
||||
max={3840}
|
||||
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="wi-height" className="text-xs text-muted-foreground">
|
||||
{s.height}
|
||||
</label>
|
||||
<input
|
||||
id="wi-height"
|
||||
type="number"
|
||||
min={64}
|
||||
max={1080}
|
||||
step={1}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(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="wi-color" className="text-xs text-muted-foreground">
|
||||
{s.color}
|
||||
</label>
|
||||
<input
|
||||
id="wi-color"
|
||||
type="text"
|
||||
maxLength={7}
|
||||
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"
|
||||
/>
|
||||
</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="waveform-image-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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Anchor,
|
||||
AppWindow,
|
||||
Archive,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
BellRing,
|
||||
BookImage,
|
||||
BookOpen,
|
||||
Braces,
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
MessageSquareText,
|
||||
MicVocal,
|
||||
Minimize2,
|
||||
Music,
|
||||
Palette,
|
||||
PenLine,
|
||||
PenTool,
|
||||
@@ -88,6 +92,7 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Wand,
|
||||
Waves,
|
||||
Wrench,
|
||||
Zap,
|
||||
ZoomIn,
|
||||
@@ -96,10 +101,13 @@ 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> = {
|
||||
Activity,
|
||||
Anchor,
|
||||
AppWindow,
|
||||
Archive,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
BellRing,
|
||||
BookImage,
|
||||
BookOpen,
|
||||
Braces,
|
||||
@@ -152,6 +160,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
MessageSquareText,
|
||||
MicVocal,
|
||||
Minimize2,
|
||||
Music,
|
||||
Palette,
|
||||
PenLine,
|
||||
PenTool,
|
||||
@@ -182,6 +191,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Wand,
|
||||
Waves,
|
||||
Wrench,
|
||||
Zap,
|
||||
ZoomIn,
|
||||
|
||||
@@ -121,9 +121,23 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
"video-metadata": "no-comparison",
|
||||
|
||||
// Audio tools
|
||||
"audio-channels": "media-player",
|
||||
"audio-metadata": "no-comparison",
|
||||
"audio-speed": "media-player",
|
||||
"convert-audio": "media-player",
|
||||
"trim-audio": "media-player",
|
||||
"extract-audio": "media-player",
|
||||
"fade-audio": "media-player",
|
||||
"merge-audio": "media-player",
|
||||
"noise-reduction": "media-player",
|
||||
"normalize-audio": "media-player",
|
||||
"pitch-shift": "media-player",
|
||||
"reverse-audio": "media-player",
|
||||
"ringtone-maker": "media-player",
|
||||
"silence-removal": "media-player",
|
||||
"split-audio": "no-comparison",
|
||||
"trim-audio": "media-player",
|
||||
"volume-adjust": "media-player",
|
||||
"waveform-image": "no-comparison",
|
||||
|
||||
// PDF & Document tools
|
||||
"merge-pdf": "document",
|
||||
@@ -165,6 +179,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
* Consumed by use-tool-processor; backend routes declare maxInputs.
|
||||
*/
|
||||
export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set([
|
||||
"merge-audio",
|
||||
"merge-pdf",
|
||||
"merge-videos",
|
||||
"replace-audio",
|
||||
|
||||
@@ -494,6 +494,76 @@ const ExtractAudioSettings = lazy(() =>
|
||||
default: m.ExtractAudioSettings,
|
||||
})),
|
||||
);
|
||||
const AudioChannelsSettings = lazy(() =>
|
||||
import("@/components/tools/audio-channels-settings").then((m) => ({
|
||||
default: m.AudioChannelsSettings,
|
||||
})),
|
||||
);
|
||||
const AudioMetadataSettings = lazy(() =>
|
||||
import("@/components/tools/audio-metadata-settings").then((m) => ({
|
||||
default: m.AudioMetadataSettings,
|
||||
})),
|
||||
);
|
||||
const AudioSpeedSettings = lazy(() =>
|
||||
import("@/components/tools/audio-speed-settings").then((m) => ({
|
||||
default: m.AudioSpeedSettings,
|
||||
})),
|
||||
);
|
||||
const FadeAudioSettings = lazy(() =>
|
||||
import("@/components/tools/fade-audio-settings").then((m) => ({
|
||||
default: m.FadeAudioSettings,
|
||||
})),
|
||||
);
|
||||
const MergeAudioSettings = lazy(() =>
|
||||
import("@/components/tools/merge-audio-settings").then((m) => ({
|
||||
default: m.MergeAudioSettings,
|
||||
})),
|
||||
);
|
||||
const NoiseReductionSettings = lazy(() =>
|
||||
import("@/components/tools/noise-reduction-settings").then((m) => ({
|
||||
default: m.NoiseReductionSettings,
|
||||
})),
|
||||
);
|
||||
const NormalizeAudioSettings = lazy(() =>
|
||||
import("@/components/tools/normalize-audio-settings").then((m) => ({
|
||||
default: m.NormalizeAudioSettings,
|
||||
})),
|
||||
);
|
||||
const PitchShiftSettings = lazy(() =>
|
||||
import("@/components/tools/pitch-shift-settings").then((m) => ({
|
||||
default: m.PitchShiftSettings,
|
||||
})),
|
||||
);
|
||||
const ReverseAudioSettings = lazy(() =>
|
||||
import("@/components/tools/reverse-audio-settings").then((m) => ({
|
||||
default: m.ReverseAudioSettings,
|
||||
})),
|
||||
);
|
||||
const RingtoneMakerSettings = lazy(() =>
|
||||
import("@/components/tools/ringtone-maker-settings").then((m) => ({
|
||||
default: m.RingtoneMakerSettings,
|
||||
})),
|
||||
);
|
||||
const SilenceRemovalSettings = lazy(() =>
|
||||
import("@/components/tools/silence-removal-settings").then((m) => ({
|
||||
default: m.SilenceRemovalSettings,
|
||||
})),
|
||||
);
|
||||
const SplitAudioSettings = lazy(() =>
|
||||
import("@/components/tools/split-audio-settings").then((m) => ({
|
||||
default: m.SplitAudioSettings,
|
||||
})),
|
||||
);
|
||||
const VolumeAdjustSettings = lazy(() =>
|
||||
import("@/components/tools/volume-adjust-settings").then((m) => ({
|
||||
default: m.VolumeAdjustSettings,
|
||||
})),
|
||||
);
|
||||
const WaveformImageSettings = lazy(() =>
|
||||
import("@/components/tools/waveform-image-settings").then((m) => ({
|
||||
default: m.WaveformImageSettings,
|
||||
})),
|
||||
);
|
||||
const MergePdfSettings = lazy(() =>
|
||||
import("@/components/tools/merge-pdf-settings").then((m) => ({
|
||||
default: m.MergePdfSettings,
|
||||
@@ -809,6 +879,22 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["trim-audio", { accept: AUDIO_INPUTS.join(","), Settings: TrimAudioSettings }],
|
||||
["extract-audio", { accept: VIDEO_INPUTS.join(","), Settings: ExtractAudioSettings }],
|
||||
|
||||
// Audio depth tools
|
||||
["audio-channels", { accept: AUDIO_INPUTS.join(","), Settings: AudioChannelsSettings }],
|
||||
["audio-metadata", { accept: AUDIO_INPUTS.join(","), Settings: AudioMetadataSettings }],
|
||||
["audio-speed", { accept: AUDIO_INPUTS.join(","), Settings: AudioSpeedSettings }],
|
||||
["fade-audio", { accept: AUDIO_INPUTS.join(","), Settings: FadeAudioSettings }],
|
||||
["merge-audio", { accept: AUDIO_INPUTS.join(","), Settings: MergeAudioSettings }],
|
||||
["noise-reduction", { accept: AUDIO_INPUTS.join(","), Settings: NoiseReductionSettings }],
|
||||
["normalize-audio", { accept: AUDIO_INPUTS.join(","), Settings: NormalizeAudioSettings }],
|
||||
["pitch-shift", { accept: AUDIO_INPUTS.join(","), Settings: PitchShiftSettings }],
|
||||
["reverse-audio", { accept: AUDIO_INPUTS.join(","), Settings: ReverseAudioSettings }],
|
||||
["ringtone-maker", { accept: AUDIO_INPUTS.join(","), Settings: RingtoneMakerSettings }],
|
||||
["silence-removal", { accept: AUDIO_INPUTS.join(","), Settings: SilenceRemovalSettings }],
|
||||
["split-audio", { accept: AUDIO_INPUTS.join(","), Settings: SplitAudioSettings }],
|
||||
["volume-adjust", { accept: AUDIO_INPUTS.join(","), Settings: VolumeAdjustSettings }],
|
||||
["waveform-image", { accept: AUDIO_INPUTS.join(","), Settings: WaveformImageSettings }],
|
||||
|
||||
// PDF & Document tools
|
||||
["merge-pdf", { accept: ".pdf", Settings: MergePdfSettings }],
|
||||
["split-pdf", { accept: ".pdf", Settings: SplitPdfSettings }],
|
||||
|
||||
@@ -13,12 +13,14 @@ const STDERR_RING_MAX = 16 * 1024;
|
||||
/**
|
||||
* Runs ffmpeg with `-progress pipe:1` appended, parsing progress blocks from
|
||||
* stdout. Rejects with the tail of stderr on non-zero exit, timeout or abort.
|
||||
* Output must go to a FILE path in args (no stdout piping of media data).
|
||||
* Resolves with the captured stderr tail (filters like silencedetect report
|
||||
* there). Output must go to a FILE path in args (no stdout piping of media
|
||||
* data).
|
||||
*/
|
||||
export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise<void> {
|
||||
export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise<string> {
|
||||
const bin = resolveFfmpeg();
|
||||
if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)");
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(bin, ["-hide_banner", "-nostdin", "-y", ...args, "-progress", "pipe:1"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -73,7 +75,7 @@ export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Pr
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (code === 0) resolvePromise();
|
||||
if (code === 0) resolvePromise(stderrTail);
|
||||
else reject(new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface MediaStreamInfo {
|
||||
width?: number;
|
||||
height?: number;
|
||||
sampleRate?: number;
|
||||
channels?: number;
|
||||
}
|
||||
|
||||
export interface MediaInfo {
|
||||
@@ -14,6 +15,7 @@ export interface MediaInfo {
|
||||
durationS: number | null;
|
||||
bitrateKbps: number | null;
|
||||
streams: MediaStreamInfo[];
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProbeOptions {
|
||||
@@ -70,17 +72,28 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro
|
||||
});
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
format?: { format_name?: string; duration?: string; bit_rate?: string };
|
||||
format?: {
|
||||
format_name?: string;
|
||||
duration?: string;
|
||||
bit_rate?: string;
|
||||
tags?: Record<string, unknown>;
|
||||
};
|
||||
streams?: Array<{
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sample_rate?: string;
|
||||
channels?: number;
|
||||
}>;
|
||||
};
|
||||
const duration = parsed.format?.duration ? Number(parsed.format.duration) : null;
|
||||
const bitRate = parsed.format?.bit_rate ? Number(parsed.format.bit_rate) : null;
|
||||
const rawTags = (parsed.format?.tags ?? {}) as Record<string, unknown>;
|
||||
const tags: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(rawTags)) {
|
||||
if (typeof v === "string" && v.length > 0) tags[k.toLowerCase()] = v;
|
||||
}
|
||||
return {
|
||||
container: parsed.format?.format_name ?? "unknown",
|
||||
durationS: Number.isFinite(duration as number) ? (duration as number) : null,
|
||||
@@ -93,7 +106,9 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
sampleRate: Number.isFinite(sr) && (sr as number) > 0 ? sr : undefined,
|
||||
channels: typeof s.channels === "number" && s.channels > 0 ? s.channels : undefined,
|
||||
} as MediaStreamInfo;
|
||||
}),
|
||||
tags: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -942,6 +942,160 @@ export const TOOLS: Tool[] = [
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "volume-adjust",
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
category: "audio",
|
||||
icon: "Volume2",
|
||||
route: "/volume-adjust",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "normalize-audio",
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
category: "audio",
|
||||
icon: "AudioWaveform",
|
||||
route: "/normalize-audio",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "fade-audio",
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
category: "audio",
|
||||
icon: "Waves",
|
||||
route: "/fade-audio",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "reverse-audio",
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
category: "audio",
|
||||
icon: "Rewind",
|
||||
route: "/reverse-audio",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "audio-speed",
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
category: "audio",
|
||||
icon: "FastForward",
|
||||
route: "/audio-speed",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "pitch-shift",
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
category: "audio",
|
||||
icon: "Music",
|
||||
route: "/pitch-shift",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "audio-channels",
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
category: "audio",
|
||||
icon: "Split",
|
||||
route: "/audio-channels",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "silence-removal",
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
category: "audio",
|
||||
icon: "VolumeX",
|
||||
route: "/silence-removal",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "noise-reduction",
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
category: "audio",
|
||||
icon: "Sparkles",
|
||||
route: "/noise-reduction",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "merge-audio",
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
category: "audio",
|
||||
icon: "Combine",
|
||||
route: "/merge-audio",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "split-audio",
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
category: "audio",
|
||||
icon: "Scissors",
|
||||
route: "/split-audio",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "ringtone-maker",
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
category: "audio",
|
||||
icon: "BellRing",
|
||||
route: "/ringtone-maker",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "waveform-image",
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
category: "audio",
|
||||
icon: "Activity",
|
||||
route: "/waveform-image",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "audio-metadata",
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
category: "audio",
|
||||
icon: "Tags",
|
||||
route: "/audio-metadata",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// PDF & Documents
|
||||
{
|
||||
id: "merge-pdf",
|
||||
|
||||
@@ -396,6 +396,62 @@ export const ar: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1708,6 +1764,117 @@ export const ar: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -401,6 +401,62 @@ export const de: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1721,6 +1777,117 @@ export const de: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -356,6 +356,62 @@ export const en = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1666,6 +1722,117 @@ export const en = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -386,6 +386,62 @@ export const es: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1703,6 +1759,117 @@ export const es: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -402,6 +402,62 @@ export const fr: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1722,6 +1778,117 @@ export const fr: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -393,6 +393,62 @@ export const hi: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1704,6 +1760,117 @@ export const hi: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -401,6 +401,62 @@ export const id: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1716,6 +1772,117 @@ export const id: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -400,6 +400,62 @@ export const it: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1715,6 +1771,117 @@ export const it: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -364,6 +364,62 @@ export const ja: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1674,6 +1730,117 @@ export const ja: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -351,6 +351,62 @@ export const ko: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1659,6 +1715,117 @@ export const ko: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -401,6 +401,62 @@ export const nl: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1718,6 +1774,117 @@ export const nl: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -402,6 +402,62 @@ export const pl: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1719,6 +1775,117 @@ export const pl: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -399,6 +399,62 @@ export const ptBR: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1715,6 +1771,117 @@ export const ptBR: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -401,6 +401,62 @@ export const ru: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1717,6 +1773,117 @@ export const ru: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -399,6 +399,62 @@ export const sv: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1714,6 +1770,117 @@ export const sv: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -394,6 +394,62 @@ export const th: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1697,6 +1753,117 @@ export const th: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -402,6 +402,62 @@ export const tr: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1719,6 +1775,117 @@ export const tr: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -401,6 +401,62 @@ export const uk: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1717,6 +1773,117 @@ export const uk: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -402,6 +402,62 @@ export const vi: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1716,6 +1772,117 @@ export const vi: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -351,6 +351,62 @@ export const zhCN: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1651,6 +1707,117 @@ export const zhCN: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
@@ -350,6 +350,62 @@ export const zhTW: TranslationKeys = {
|
||||
name: "Extract Audio",
|
||||
description: "Pull the audio track out of a video",
|
||||
},
|
||||
"volume-adjust": {
|
||||
name: "Volume Adjust",
|
||||
description: "Increase or decrease audio volume by a fixed gain",
|
||||
},
|
||||
"normalize-audio": {
|
||||
name: "Normalize Audio",
|
||||
description: "Even out loudness to broadcast standard levels",
|
||||
},
|
||||
"fade-audio": {
|
||||
name: "Fade Audio",
|
||||
description: "Add fade-in and fade-out effects to audio",
|
||||
},
|
||||
"reverse-audio": {
|
||||
name: "Reverse Audio",
|
||||
description: "Play audio backwards",
|
||||
},
|
||||
"audio-speed": {
|
||||
name: "Audio Speed",
|
||||
description: "Speed up or slow down audio playback",
|
||||
},
|
||||
"pitch-shift": {
|
||||
name: "Pitch Shift",
|
||||
description: "Raise or lower pitch by semitones without changing speed",
|
||||
},
|
||||
"audio-channels": {
|
||||
name: "Audio Channels",
|
||||
description: "Convert between mono, stereo, or swap left and right channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
name: "Silence Removal",
|
||||
description: "Strip silent sections from audio",
|
||||
},
|
||||
"noise-reduction": {
|
||||
name: "Noise Reduction",
|
||||
description: "Reduce background noise with FFT-based denoising",
|
||||
},
|
||||
"merge-audio": {
|
||||
name: "Merge Audio",
|
||||
description: "Combine multiple audio files into one",
|
||||
},
|
||||
"split-audio": {
|
||||
name: "Split Audio",
|
||||
description: "Split audio by time, equal parts, or silence detection",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
name: "Ringtone Maker",
|
||||
description: "Create a ringtone clip from any audio file",
|
||||
},
|
||||
"waveform-image": {
|
||||
name: "Waveform Image",
|
||||
description: "Generate a waveform visualization as a PNG image",
|
||||
},
|
||||
"audio-metadata": {
|
||||
name: "Audio Metadata",
|
||||
description: "View, edit, or strip audio tags (ID3)",
|
||||
},
|
||||
"merge-pdf": {
|
||||
name: "Merge PDFs",
|
||||
description: "Combine multiple PDFs into one",
|
||||
@@ -1649,6 +1705,117 @@ export const zhTW: TranslationKeys = {
|
||||
submitBatch: "Extract ({count} files)",
|
||||
progressLabel: "Extracting",
|
||||
},
|
||||
"volume-adjust": {
|
||||
"gain-db": "Gain (dB)",
|
||||
submit: "Adjust Volume",
|
||||
submitBatch: "Adjust Volume ({count} files)",
|
||||
progressLabel: "Adjusting volume",
|
||||
},
|
||||
"normalize-audio": {
|
||||
hint: "EBU R128 loudness normalization",
|
||||
submit: "Normalize",
|
||||
submitBatch: "Normalize ({count} files)",
|
||||
progressLabel: "Normalizing",
|
||||
},
|
||||
"fade-audio": {
|
||||
"fade-in-s": "Fade in (seconds)",
|
||||
"fade-out-s": "Fade out (seconds)",
|
||||
submit: "Apply Fade",
|
||||
submitBatch: "Apply Fade ({count} files)",
|
||||
progressLabel: "Applying fade",
|
||||
},
|
||||
"reverse-audio": {
|
||||
submit: "Reverse",
|
||||
submitBatch: "Reverse ({count} files)",
|
||||
progressLabel: "Reversing",
|
||||
},
|
||||
"audio-speed": {
|
||||
factor: "Speed factor",
|
||||
submit: "Change Speed",
|
||||
submitBatch: "Change Speed ({count} files)",
|
||||
progressLabel: "Changing speed",
|
||||
},
|
||||
"pitch-shift": {
|
||||
semitones: "Semitones",
|
||||
hint: "0 semitones is not allowed",
|
||||
submit: "Shift Pitch",
|
||||
submitBatch: "Shift Pitch ({count} files)",
|
||||
progressLabel: "Shifting pitch",
|
||||
},
|
||||
"audio-channels": {
|
||||
mode: "Channel mode",
|
||||
"stereo-to-mono": "Stereo to mono",
|
||||
"mono-to-stereo": "Mono to stereo",
|
||||
swap: "Swap left/right",
|
||||
submit: "Convert Channels",
|
||||
submitBatch: "Convert Channels ({count} files)",
|
||||
progressLabel: "Converting channels",
|
||||
},
|
||||
"silence-removal": {
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Remove Silence",
|
||||
submitBatch: "Remove Silence ({count} files)",
|
||||
progressLabel: "Removing silence",
|
||||
},
|
||||
"noise-reduction": {
|
||||
strength: "Strength",
|
||||
light: "Light",
|
||||
medium: "Medium",
|
||||
strong: "Strong",
|
||||
submit: "Reduce Noise",
|
||||
submitBatch: "Reduce Noise ({count} files)",
|
||||
progressLabel: "Reducing noise",
|
||||
},
|
||||
"merge-audio": {
|
||||
format: "Output format",
|
||||
mp3: "MP3",
|
||||
wav: "WAV",
|
||||
flac: "FLAC",
|
||||
m4a: "M4A",
|
||||
"order-hint": "Files will be joined in upload order",
|
||||
submit: "Merge Audio",
|
||||
submitBatch: "Merge Audio ({count} files)",
|
||||
progressLabel: "Merging audio",
|
||||
},
|
||||
"split-audio": {
|
||||
mode: "Split mode",
|
||||
time: "By time",
|
||||
parts: "Equal parts",
|
||||
silence: "By silence",
|
||||
"segment-s": "Segment length (seconds)",
|
||||
"num-parts": "Number of parts",
|
||||
"threshold-db": "Silence threshold (dB)",
|
||||
"min-silence-s": "Minimum silence duration (seconds)",
|
||||
submit: "Split Audio",
|
||||
submitBatch: "Split Audio ({count} files)",
|
||||
progressLabel: "Splitting audio",
|
||||
},
|
||||
"ringtone-maker": {
|
||||
"start-s": "Start time (seconds)",
|
||||
"duration-s": "Duration (seconds)",
|
||||
"max-hint": "Maximum 30 seconds",
|
||||
submit: "Create Ringtone",
|
||||
submitBatch: "Create Ringtone ({count} files)",
|
||||
progressLabel: "Creating ringtone",
|
||||
},
|
||||
"waveform-image": {
|
||||
width: "Width (px)",
|
||||
height: "Height (px)",
|
||||
color: "Waveform color",
|
||||
submit: "Generate Waveform",
|
||||
submitBatch: "Generate Waveform ({count} files)",
|
||||
progressLabel: "Generating waveform",
|
||||
},
|
||||
"audio-metadata": {
|
||||
strip: "Strip all metadata",
|
||||
title: "Title",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
submit: "Apply Metadata",
|
||||
submitBatch: "Apply Metadata ({count} files)",
|
||||
progressLabel: "Applying metadata",
|
||||
},
|
||||
"merge-pdf": {
|
||||
addFiles: "Add PDFs to merge",
|
||||
needTwo: "At least two PDFs are required",
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -29,6 +29,7 @@ export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
|
||||
"rotate-video": { transform: "cw90" },
|
||||
"resize-video": { preset: "720p" },
|
||||
"watermark-video": { text: "CONFIDENTIAL" },
|
||||
"audio-channels": { mode: "mono-to-stereo" },
|
||||
};
|
||||
|
||||
export function defaultSettingsFor(toolId: string): unknown {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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 STEREO = readFileSync(join(__dirname, "..", "fixtures", "media", "tone-stereo.wav"));
|
||||
const MONO_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);
|
||||
|
||||
function postTool(
|
||||
filename: string,
|
||||
contentTypeHeader: string,
|
||||
fileContent: Buffer,
|
||||
settings: Record<string, unknown>,
|
||||
) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename, contentType: contentTypeHeader, content: fileContent },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio-channels",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("audio-channels (requires ffmpeg)", () => {
|
||||
it("stereo-to-mono on stereo file produces mono output", async () => {
|
||||
const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, {
|
||||
mode: "stereo-to-mono",
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "ch-test-"));
|
||||
const probeFile = join(tmpDir, "mono.wav");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio?.channels).toBe(1);
|
||||
}, 60_000);
|
||||
|
||||
it("swap on stereo file returns 200 with 2 channels", async () => {
|
||||
const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, { mode: "swap" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "ch-test-"));
|
||||
const probeFile = join(tmpDir, "swapped.wav");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio?.channels).toBe(2);
|
||||
}, 60_000);
|
||||
|
||||
it("stereo-to-mono on mono input returns 422", async () => {
|
||||
const res = await postTool("tiny.mp3", "audio/mpeg", MONO_MP3, {
|
||||
mode: "stereo-to-mono",
|
||||
});
|
||||
expect(res.statusCode).toBe(422);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.details).toMatch(/stereo input/i);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects missing mode (400)", async () => {
|
||||
const res = await postTool("tone-stereo.wav", "audio/wav", STEREO, {});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio-metadata",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("audio-metadata (requires ffmpeg)", () => {
|
||||
it("sets title tag and envelope contains metadata with tags object", async () => {
|
||||
const res = await runTool({ title: "EnvelopeTitle2026" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
|
||||
// Envelope carries resultPayload.metadata at top level (sync passthrough)
|
||||
expect(envelope.metadata).toBeDefined();
|
||||
expect(typeof envelope.metadata.tags).toBe("object");
|
||||
|
||||
// Download and probe the output to verify the tag was written
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "audio-meta-"));
|
||||
const probeFile = join(tmpDir, "tagged.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.tags?.title).toBe("EnvelopeTitle2026");
|
||||
}, 60_000);
|
||||
|
||||
it("strips metadata with strip true and returns 200", async () => {
|
||||
const res = await runTool({ strip: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
}, 60_000);
|
||||
|
||||
it("rejects title exceeding 500 characters (400)", async () => {
|
||||
const res = await runTool({ title: "x".repeat(501) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/audio-speed",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("audio-speed (requires ffmpeg)", () => {
|
||||
it("doubles speed and halves duration", async () => {
|
||||
const res = await runTool({ factor: 2 });
|
||||
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(), "aspeed-test-"));
|
||||
const probeFile = join(tmpDir, "sped.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// tiny.mp3 is ~1.044s; at 2x speed output should be ~0.522s (within 25%)
|
||||
const expected = 1.044 / 2;
|
||||
expect(info.durationS).toBeGreaterThan(expected * 0.75);
|
||||
expect(info.durationS).toBeLessThan(expected * 1.25);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects factor out of range", async () => {
|
||||
const res = await runTool({ factor: 10 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/fade-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("fade-audio (requires ffmpeg)", () => {
|
||||
it("applies default fades and returns 200", async () => {
|
||||
const res = await runTool({ fadeInS: 1, fadeOutS: 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);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects when both fades are 0", async () => {
|
||||
const res = await runTool({ fadeInS: 0, fadeOutS: 0 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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 MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3"));
|
||||
const WAV = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.wav"));
|
||||
|
||||
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())("merge-audio (requires ffmpeg)", () => {
|
||||
it("merges two audio files with different sample rates", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "file", filename: "tiny.wav", contentType: "audio/wav", content: WAV },
|
||||
{ name: "settings", content: JSON.stringify({ format: "mp3" }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/merge-audio",
|
||||
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);
|
||||
|
||||
// Probe: duration should be close to 1.044 + 1.0 = 2.044s
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "merge-audio-test-"));
|
||||
const probeFile = join(tmpDir, "merged.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const expected = 2.04;
|
||||
expect(info.durationS).toBeDefined();
|
||||
expect(Math.abs((info.durationS ?? 0) - expected)).toBeLessThan(expected * 0.3);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects when only one file is provided", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ 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/merge-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.details).toMatch(/at least two/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/noise-reduction",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("noise-reduction (requires ffmpeg)", () => {
|
||||
it("denoises with medium strength and returns audio", async () => {
|
||||
const res = await runTool({ strength: "medium" });
|
||||
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(), "denoise-test-"));
|
||||
const probeFile = join(tmpDir, "denoised.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio).toBeDefined();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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 WAV = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.wav"));
|
||||
|
||||
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.wav", contentType: "audio/wav", content: WAV },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/normalize-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("normalize-audio (requires ffmpeg)", () => {
|
||||
it("normalizes 8 kHz WAV 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);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pitch-shift",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("pitch-shift (requires ffmpeg)", () => {
|
||||
it("shifts +12 semitones with duration roughly unchanged", async () => {
|
||||
const res = await runTool({ semitones: 12 });
|
||||
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(), "pitch-test-"));
|
||||
const probeFile = join(tmpDir, "pitched.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// rubberband preserves tempo; duration should be roughly 1.044s (30% tolerance)
|
||||
expect(info.durationS).toBeGreaterThan(1.044 * 0.7);
|
||||
expect(info.durationS).toBeLessThan(1.044 * 1.3);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects semitones 0", async () => {
|
||||
const res = await runTool({ semitones: 0 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/reverse-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("reverse-audio (requires ffmpeg)", () => {
|
||||
it("reverses audio and returns 200 with duration roughly matching source", async () => {
|
||||
// Probe source duration
|
||||
const srcTmpDir = mkdtempSync(join(tmpdir(), "rev-src-"));
|
||||
const srcFile = join(srcTmpDir, "tiny.mp3");
|
||||
writeFileSync(srcFile, MP3);
|
||||
const srcInfo = await probeMedia(srcFile);
|
||||
|
||||
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.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
expect(info.durationS).not.toBeNull();
|
||||
// Duration should be roughly the same (30% tolerance)
|
||||
const srcDur = srcInfo.durationS ?? 1;
|
||||
expect(info.durationS as number).toBeGreaterThan(srcDur * 0.7);
|
||||
expect(info.durationS as number).toBeLessThan(srcDur * 1.3);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/ringtone-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("ringtone-maker (requires ffmpeg)", () => {
|
||||
it("creates m4r ringtone with defaults 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(200);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects startS beyond audio duration (422)", async () => {
|
||||
const res = await runTool({ startS: 5 });
|
||||
expect(res.statusCode).toBe(422);
|
||||
expect(res.body).toMatch(/beyond the end/i);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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 TONE_GAP = readFileSync(join(__dirname, "..", "fixtures", "media", "tone-gap.wav"));
|
||||
|
||||
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: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/silence-removal",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("silence-removal (requires ffmpeg)", () => {
|
||||
it("removes silent gap and shortens duration below 1.05s", async () => {
|
||||
const res = await runTool({ thresholdDb: -40, minSilenceS: 0.2 });
|
||||
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(), "silence-test-"));
|
||||
const probeFile = join(tmpDir, "nosilence.wav");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
|
||||
// tone-gap.wav is ~1.2s with a 0.4s silent middle; output should be shorter than 1.05s
|
||||
expect(info.durationS).toBeLessThan(1.05);
|
||||
expect(info.durationS).toBeGreaterThan(0.3);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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 MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3"));
|
||||
const TONE_GAP = readFileSync(join(__dirname, "..", "fixtures", "media", "tone-gap.wav"));
|
||||
|
||||
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())("split-audio (requires ffmpeg)", () => {
|
||||
it("splits into 2 parts via parts mode", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify({ mode: "parts", parts: 2 }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/split-audio",
|
||||
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);
|
||||
// ZIP magic: PK\x03\x04
|
||||
expect(dl.rawPayload[0]).toBe(0x50);
|
||||
expect(dl.rawPayload[1]).toBe(0x4b);
|
||||
}, 60_000);
|
||||
|
||||
it("splits by silence on tone-gap.wav", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ mode: "silence", thresholdDb: -40, minSilenceS: 0.2 }),
|
||||
},
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
// ZIP magic
|
||||
expect(dl.rawPayload[0]).toBe(0x50);
|
||||
expect(dl.rawPayload[1]).toBe(0x4b);
|
||||
expect(dl.rawPayload.length).toBeGreaterThan(400);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects silence mode when no silence found", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ mode: "silence", thresholdDb: -40, minSilenceS: 0.2 }),
|
||||
},
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.details).toMatch(/no silence found/i);
|
||||
}, 60_000);
|
||||
|
||||
it("splits by time mode on tone-gap.wav (segmentS=1)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tone-gap.wav", contentType: "audio/wav", content: TONE_GAP },
|
||||
{ name: "settings", content: JSON.stringify({ mode: "time", segmentS: 1 }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
expect(dl.statusCode).toBe(200);
|
||||
// ZIP magic
|
||||
expect(dl.rawPayload[0]).toBe(0x50);
|
||||
expect(dl.rawPayload[1]).toBe(0x4b);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects parts=1 (schema minimum is 2)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify({ mode: "parts", parts: 1 }) },
|
||||
]);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/split-audio",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/volume-adjust",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("volume-adjust (requires ffmpeg)", () => {
|
||||
it("adjusts volume and returns 200 with audio stream", async () => {
|
||||
const res = await runTool({ gainDb: 3 });
|
||||
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(), "vol-test-"));
|
||||
const probeFile = join(tmpDir, "adjusted.mp3");
|
||||
writeFileSync(probeFile, dl.rawPayload);
|
||||
const info = await probeMedia(probeFile);
|
||||
const audio = info.streams.find((s) => s.type === "audio");
|
||||
expect(audio).toBeDefined();
|
||||
}, 60_000);
|
||||
|
||||
it("rejects gainDb out of range (50)", async () => {
|
||||
const res = await runTool({ gainDb: 50 });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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 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);
|
||||
|
||||
async function runTool(settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/waveform-image",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!ffmpegAvailable())("waveform-image (requires ffmpeg)", () => {
|
||||
it("generates PNG waveform with default settings", 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);
|
||||
|
||||
// PNG magic bytes: 89 50 4E 47
|
||||
expect(dl.rawPayload[0]).toBe(0x89);
|
||||
expect(dl.rawPayload[1]).toBe(0x50);
|
||||
expect(dl.rawPayload[2]).toBe(0x4e);
|
||||
expect(dl.rawPayload[3]).toBe(0x47);
|
||||
}, 60_000);
|
||||
|
||||
it("rejects invalid color string (400)", async () => {
|
||||
const res = await runTool({ color: "blue" });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { audioOutputFor } from "../../../apps/api/src/lib/media-tool.js";
|
||||
|
||||
describe("audioOutputFor", () => {
|
||||
it("normalises uppercase extensions", () => {
|
||||
const out = audioOutputFor(".MP3");
|
||||
expect(out.ext).toBe(".mp3");
|
||||
expect(out.contentType).toBe("audio/mpeg");
|
||||
expect(out.encodeArgs).toContain("libmp3lame");
|
||||
});
|
||||
|
||||
it("falls back to mp3 for decode-only formats", () => {
|
||||
const out = audioOutputFor(".wma");
|
||||
expect(out.ext).toBe(".mp3");
|
||||
expect(out.contentType).toBe("audio/mpeg");
|
||||
});
|
||||
|
||||
it("maps .aac input to .m4a output", () => {
|
||||
const out = audioOutputFor(".aac");
|
||||
expect(out.ext).toBe(".m4a");
|
||||
expect(out.contentType).toBe("audio/mp4");
|
||||
expect(out.encodeArgs).toContain("aac");
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ vi.mock("sharp", () => ({
|
||||
|
||||
// ── Imports ─────────────────────────────────────────────────────────────
|
||||
|
||||
import { waitForJob } from "../../../apps/api/src/jobs/enqueue.js";
|
||||
import { enqueueToolJob, waitForJob } from "../../../apps/api/src/jobs/enqueue.js";
|
||||
import { isToolInstalled } from "../../../apps/api/src/lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../../apps/api/src/lib/file-validation.js";
|
||||
import type { AnyToolRouteConfig } from "../../../apps/api/src/routes/tool-factory.js";
|
||||
@@ -575,4 +575,149 @@ describe("createToolRoute", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multipart field recovery", () => {
|
||||
it("recovers settings from part.fields when the iterator drops trailing fields", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const reply = createMockReply();
|
||||
|
||||
// Simulate the @fastify/multipart race: the iterator yields only the
|
||||
// file part; the settings field is present only on part.fields.
|
||||
const req = {
|
||||
parts: () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "file",
|
||||
filename: "test.png",
|
||||
file: (async function* () {
|
||||
yield Buffer.from("png-data");
|
||||
})(),
|
||||
fields: {
|
||||
settings: { value: '{"x":1}' },
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
};
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
// Settings must be recovered as {x:1}, not fall through to defaults ({})
|
||||
const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0];
|
||||
expect(enqueueCall.settings).toEqual({ x: 1 });
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not overwrite settings already collected from the iterator", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const reply = createMockReply();
|
||||
|
||||
// Both the iterator and part.fields carry settings; the iterator value wins
|
||||
const req = {
|
||||
parts: () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "file",
|
||||
filename: "test.png",
|
||||
file: (async function* () {
|
||||
yield Buffer.from("png-data");
|
||||
})(),
|
||||
fields: {
|
||||
settings: { value: '{"from":"fields"}' },
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "field",
|
||||
fieldname: "settings",
|
||||
value: '{"from":"iterator"}',
|
||||
file: (async function* () {})(),
|
||||
fields: {
|
||||
settings: { value: '{"from":"fields"}' },
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
};
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0];
|
||||
expect(enqueueCall.settings).toEqual({ from: "iterator" });
|
||||
});
|
||||
|
||||
it("recovers fileId and clientJobId from part.fields", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const reply = createMockReply();
|
||||
|
||||
const req = {
|
||||
parts: () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "file",
|
||||
filename: "test.png",
|
||||
file: (async function* () {
|
||||
yield Buffer.from("png-data");
|
||||
})(),
|
||||
fields: {
|
||||
settings: { value: "{}" },
|
||||
fileId: { value: "f-123" },
|
||||
clientJobId: { value: "cj-456" },
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
};
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0];
|
||||
expect(enqueueCall.fileId).toBe("f-123");
|
||||
expect(enqueueCall.clientJobId).toBe("cj-456");
|
||||
});
|
||||
|
||||
it("handles array-form fields from part.fields", async () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[`/api/v1/tools/${id}`];
|
||||
const reply = createMockReply();
|
||||
|
||||
const req = {
|
||||
parts: () => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
type: "file",
|
||||
filename: "test.png",
|
||||
file: (async function* () {
|
||||
yield Buffer.from("png-data");
|
||||
})(),
|
||||
fields: {
|
||||
settings: [{ value: '{"arr":true}' }],
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
};
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0];
|
||||
expect(enqueueCall.settings).toEqual({ arr: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAtempoChain } from "../../../apps/api/src/routes/tools/video-speed.js";
|
||||
import { buildAtempoChain } from "../../../apps/api/src/lib/media-tool.js";
|
||||
|
||||
describe("buildAtempoChain", () => {
|
||||
it("returns a single atempo for factor >= 0.5", () => {
|
||||
|
||||
Reference in New Issue
Block a user