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 5a - image gap-fill (11 tools) (#225)
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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"bullmq": "^5.78.0",
|
||||
"bwip-js": "^4.11.1",
|
||||
"dotenv": "^16.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"exceljs": "^4.4.0",
|
||||
|
||||
@@ -104,6 +104,82 @@ export async function encodeQoi(inputBuffer: Buffer): Promise<Buffer> {
|
||||
return Buffer.from(encoded);
|
||||
}
|
||||
|
||||
export async function encodePpm(inputBuffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `ppm-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `ppm-enc-out-${id}.ppm`);
|
||||
try {
|
||||
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `ppm:${outputPath}`]), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeEps(inputBuffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `eps-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `eps-enc-out-${id}.eps`);
|
||||
try {
|
||||
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `eps:${outputPath}`]), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeTga(inputBuffer: Buffer): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `tga-enc-in-${id}.png`);
|
||||
const outputPath = join(tmpdir(), `tga-enc-out-${id}.tga`);
|
||||
try {
|
||||
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||
await writeFile(inputPath, pngBuffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `tga:${outputPath}`]), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeMultiIco(pngPaths: string[], outputPath: string): Promise<void> {
|
||||
const cmd = await findMagickCmd();
|
||||
const args =
|
||||
cmd === "magick" ? [...pngPaths, `ico:${outputPath}`] : [...pngPaths, `ico:${outputPath}`];
|
||||
await execFileAsync(cmd, cmd === "magick" ? ["convert", ...args] : args, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether ImageMagick is available on this system.
|
||||
* Returns true if magick or convert can be found; false otherwise.
|
||||
*/
|
||||
export async function hasMagick(): Promise<boolean> {
|
||||
try {
|
||||
await findMagickCmd();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeJxl(inputBuffer: Buffer, quality?: number): Promise<Buffer> {
|
||||
const id = randomUUID();
|
||||
const inputPath = join(tmpdir(), `jxl-enc-in-${id}.png`);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import bwipjs from "bwip-js/node";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { putObject } from "../../lib/object-storage.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
text: z.string().min(1).max(256),
|
||||
type: z.enum(["code128", "ean13", "upca", "code39", "itf14", "datamatrix"]).default("code128"),
|
||||
scale: z.number().int().min(1).max(8).default(3),
|
||||
includeText: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* Barcode generator - custom route (not factory) since it generates
|
||||
* images from text input, not from uploaded files.
|
||||
* Mirrors qr-generate.ts exactly in route shape.
|
||||
*/
|
||||
export function registerBarcodeGenerate(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/barcode-generate",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = request.body;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid request body" });
|
||||
}
|
||||
|
||||
const result = settingsSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
const settings = result.data;
|
||||
|
||||
try {
|
||||
const buffer = await bwipjs.toBuffer({
|
||||
bcid: settings.type,
|
||||
text: settings.text,
|
||||
scale: settings.scale,
|
||||
includetext: settings.includeText,
|
||||
});
|
||||
|
||||
const jobId = randomUUID();
|
||||
const filename = "barcode.png";
|
||||
await putObject(`outputs/${jobId}/${filename}`, buffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
||||
originalSize: 0,
|
||||
processedSize: buffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.split("\n")[0] : "Unknown error";
|
||||
return reply.status(400).send({
|
||||
error: `Invalid text for this barcode type: ${msg}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import Papa from "papaparse";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
kind: z.enum(["bar", "line", "pie"]).default("bar"),
|
||||
title: z.string().max(120).optional(),
|
||||
width: z.number().int().min(320).max(2048).default(960),
|
||||
height: z.number().int().min(240).max(1536).default(540),
|
||||
});
|
||||
|
||||
const PALETTE = [
|
||||
"#4e79a7",
|
||||
"#f28e2b",
|
||||
"#e15759",
|
||||
"#76b7b2",
|
||||
"#59a14f",
|
||||
"#edc948",
|
||||
"#b07aa1",
|
||||
"#ff9da7",
|
||||
"#9c755f",
|
||||
"#bab0ac",
|
||||
];
|
||||
|
||||
/** XML-escape user-supplied text before embedding in SVG. */
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
interface DataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function parseInput(buf: Buffer, filename: string): DataPoint[] {
|
||||
const lower = filename.toLowerCase();
|
||||
|
||||
if (lower.endsWith(".json")) {
|
||||
const raw: unknown = JSON.parse(buf.toString("utf8"));
|
||||
// Array of {label, value}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item: Record<string, unknown>) => ({
|
||||
label: String(item.label ?? ""),
|
||||
value: Number(item.value),
|
||||
}));
|
||||
}
|
||||
// Object: key -> number
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.entries(raw as Record<string, unknown>).map(([label, value]) => ({
|
||||
label,
|
||||
value: Number(value),
|
||||
}));
|
||||
}
|
||||
throw new Error("JSON must be an array of {label,value} or an object");
|
||||
}
|
||||
|
||||
// CSV: column 1 = label, column 2 = numeric value
|
||||
const parsed = Papa.parse<string[]>(buf.toString("utf8"), {
|
||||
header: false,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new Error(`CSV parse failed: ${parsed.errors[0].message}`);
|
||||
}
|
||||
|
||||
const rows = parsed.data;
|
||||
// Skip header row if first row col2 is non-numeric
|
||||
let start = 0;
|
||||
if (rows.length > 1 && Number.isNaN(Number(rows[0][1]))) {
|
||||
start = 1;
|
||||
}
|
||||
|
||||
return rows.slice(start).map((row) => ({
|
||||
label: String(row[0] ?? ""),
|
||||
value: Number(row[1]),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderBarSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string {
|
||||
const margin = { top: title ? 40 : 20, right: 20, bottom: 60, left: 50 };
|
||||
const plotW = w - margin.left - margin.right;
|
||||
const plotH = h - margin.top - margin.bottom;
|
||||
const maxVal = Math.max(...data.map((d) => d.value), 1);
|
||||
const barW = plotW / data.length;
|
||||
const rotateLabels = data.length > 8;
|
||||
|
||||
let bars = "";
|
||||
let labels = "";
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const barH = (data[i].value / maxVal) * plotH;
|
||||
const x = margin.left + i * barW + barW * 0.1;
|
||||
const y = margin.top + plotH - barH;
|
||||
const bw = barW * 0.8;
|
||||
bars += `<rect x="${x}" y="${y}" width="${bw}" height="${barH}" fill="${PALETTE[i % PALETTE.length]}"/>`;
|
||||
const lx = margin.left + i * barW + barW / 2;
|
||||
const ly = margin.top + plotH + 14;
|
||||
if (rotateLabels) {
|
||||
labels += `<text x="${lx}" y="${ly}" text-anchor="end" font-size="10" font-family="sans-serif" transform="rotate(-45,${lx},${ly})">${escapeXml(data[i].label)}</text>`;
|
||||
} else {
|
||||
labels += `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="10" font-family="sans-serif">${escapeXml(data[i].label)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Axis line
|
||||
const axisLine = `<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#333" stroke-width="1"/>`;
|
||||
|
||||
const titleSvg = title
|
||||
? `<text x="${w / 2}" y="24" text-anchor="middle" font-size="14" font-weight="bold" font-family="sans-serif">${escapeXml(title)}</text>`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="white"/>${titleSvg}${axisLine}${bars}${labels}</svg>`;
|
||||
}
|
||||
|
||||
function renderLineSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string {
|
||||
const margin = { top: title ? 40 : 20, right: 20, bottom: 60, left: 50 };
|
||||
const plotW = w - margin.left - margin.right;
|
||||
const plotH = h - margin.top - margin.bottom;
|
||||
const maxVal = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
const points: string[] = [];
|
||||
let dots = "";
|
||||
let labels = "";
|
||||
const rotateLabels = data.length > 8;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = margin.left + (i / Math.max(data.length - 1, 1)) * plotW;
|
||||
const y = margin.top + plotH - (data[i].value / maxVal) * plotH;
|
||||
points.push(`${x},${y}`);
|
||||
dots += `<circle cx="${x}" cy="${y}" r="3" fill="${PALETTE[0]}"/>`;
|
||||
|
||||
const ly = margin.top + plotH + 14;
|
||||
if (rotateLabels) {
|
||||
labels += `<text x="${x}" y="${ly}" text-anchor="end" font-size="10" font-family="sans-serif" transform="rotate(-45,${x},${ly})">${escapeXml(data[i].label)}</text>`;
|
||||
} else {
|
||||
labels += `<text x="${x}" y="${ly}" text-anchor="middle" font-size="10" font-family="sans-serif">${escapeXml(data[i].label)}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
const polyline = `<polyline points="${points.join(" ")}" fill="none" stroke="${PALETTE[0]}" stroke-width="2"/>`;
|
||||
const axisLine = `<line x1="${margin.left}" y1="${margin.top + plotH}" x2="${margin.left + plotW}" y2="${margin.top + plotH}" stroke="#333" stroke-width="1"/>`;
|
||||
|
||||
const titleSvg = title
|
||||
? `<text x="${w / 2}" y="24" text-anchor="middle" font-size="14" font-weight="bold" font-family="sans-serif">${escapeXml(title)}</text>`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="white"/>${titleSvg}${axisLine}${polyline}${dots}${labels}</svg>`;
|
||||
}
|
||||
|
||||
function renderPieSvg(data: DataPoint[], w: number, h: number, title: string | undefined): string {
|
||||
const cx = w / 2 - 60;
|
||||
const cy = h / 2 + (title ? 10 : 0);
|
||||
const r = Math.min(cx, cy) - 30;
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
||||
|
||||
let angle = -Math.PI / 2;
|
||||
let slices = "";
|
||||
let legend = "";
|
||||
const legendX = cx + r + 30;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const fraction = data[i].value / total;
|
||||
const endAngle = angle + fraction * Math.PI * 2;
|
||||
|
||||
const x1 = cx + r * Math.cos(angle);
|
||||
const y1 = cy + r * Math.sin(angle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
const largeArc = fraction > 0.5 ? 1 : 0;
|
||||
|
||||
slices += `<path d="M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z" fill="${PALETTE[i % PALETTE.length]}"/>`;
|
||||
|
||||
const ly = 40 + i * 18;
|
||||
legend += `<rect x="${legendX}" y="${ly - 8}" width="10" height="10" fill="${PALETTE[i % PALETTE.length]}"/>`;
|
||||
legend += `<text x="${legendX + 14}" y="${ly}" font-size="10" font-family="sans-serif">${escapeXml(data[i].label)}</text>`;
|
||||
|
||||
angle = endAngle;
|
||||
}
|
||||
|
||||
const titleSvg = title
|
||||
? `<text x="${w / 2}" y="24" text-anchor="middle" font-size="14" font-weight="bold" font-family="sans-serif">${escapeXml(title)}</text>`
|
||||
: "";
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="white"/>${titleSvg}${slices}${legend}</svg>`;
|
||||
}
|
||||
|
||||
export function registerChartMaker(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "chart-maker",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("chart-maker is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const input = ctx.inputs[0];
|
||||
const base = input.filename.replace(/\.[^.]+$/, "");
|
||||
|
||||
let data: DataPoint[];
|
||||
try {
|
||||
data = parseInput(input.buffer, input.filename);
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : "Failed to parse input");
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error("No data points found in input");
|
||||
}
|
||||
if (data.length > 100) {
|
||||
throw new Error("Too many data points (max 100)");
|
||||
}
|
||||
|
||||
// Validate numeric values
|
||||
for (const point of data) {
|
||||
if (Number.isNaN(point.value)) {
|
||||
throw new Error("Column 2 must be numeric");
|
||||
}
|
||||
}
|
||||
|
||||
let svg: string;
|
||||
switch (settings.kind) {
|
||||
case "bar":
|
||||
svg = renderBarSvg(data, settings.width, settings.height, settings.title);
|
||||
break;
|
||||
case "line":
|
||||
svg = renderLineSvg(data, settings.width, settings.height, settings.title);
|
||||
break;
|
||||
case "pie":
|
||||
svg = renderPieSvg(data, settings.width, settings.height, settings.title);
|
||||
break;
|
||||
}
|
||||
|
||||
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
|
||||
return {
|
||||
buffer: pngBuffer,
|
||||
filename: `${base}_chart.png`,
|
||||
contentType: "image/png",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerCircleCrop(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "circle-crop",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, _settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
const d = Math.min(w, h);
|
||||
|
||||
// Extract centered square
|
||||
const left = Math.floor((w - d) / 2);
|
||||
const top = Math.floor((h - d) / 2);
|
||||
const squareBuf = await sharp(inputBuffer)
|
||||
.extract({ left, top, width: d, height: d })
|
||||
.toBuffer();
|
||||
|
||||
// Create SVG circle mask
|
||||
const r = d / 2;
|
||||
const mask = Buffer.from(
|
||||
`<svg width="${d}" height="${d}"><circle cx="${r}" cy="${r}" r="${r}" fill="white"/></svg>`,
|
||||
);
|
||||
|
||||
// Composite with dest-in blend to mask
|
||||
const buffer = await sharp(squareBuf)
|
||||
.ensureAlpha()
|
||||
.composite([{ input: await sharp(mask).resize(d, d).toBuffer(), blend: "dest-in" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_circle.png`,
|
||||
contentType: "image/png",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,10 +10,13 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
encodeBmp,
|
||||
encodeEps,
|
||||
encodeIco,
|
||||
encodeJp2,
|
||||
encodeJxl,
|
||||
encodePpm,
|
||||
encodeQoi,
|
||||
encodeTga,
|
||||
} from "../../lib/format-encoders.js";
|
||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
||||
@@ -56,16 +59,24 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||
jp2: "image/jp2",
|
||||
qoi: "image/x-qoi",
|
||||
psd: "image/vnd.adobe.photoshop",
|
||||
ppm: "image/x-portable-pixmap",
|
||||
eps: "application/postscript",
|
||||
tga: "image/x-tga",
|
||||
};
|
||||
|
||||
const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Buffer>> = {
|
||||
bmp: encodeBmp,
|
||||
eps: encodeEps,
|
||||
ico: encodeIco,
|
||||
jp2: encodeJp2,
|
||||
jxl: encodeJxl,
|
||||
ppm: encodePpm,
|
||||
qoi: encodeQoi,
|
||||
tga: encodeTga,
|
||||
};
|
||||
|
||||
const ANIMATABLE_FORMATS = new Set(["gif", "webp"]);
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum([
|
||||
"jpg",
|
||||
@@ -82,6 +93,9 @@ const settingsSchema = z.object({
|
||||
"jp2",
|
||||
"qoi",
|
||||
"psd",
|
||||
"ppm",
|
||||
"eps",
|
||||
"tga",
|
||||
]),
|
||||
quality: z.number().min(1).max(100).optional(),
|
||||
});
|
||||
@@ -105,7 +119,14 @@ export function registerConvert(app: FastifyInstance) {
|
||||
};
|
||||
}
|
||||
|
||||
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
|
||||
const inputExt = extname(filename).toLowerCase().replace(".", "");
|
||||
const sharpOpts: import("sharp").SharpOptions = isSvgBuffer(inputBuffer)
|
||||
? { density: 300 }
|
||||
: {};
|
||||
// Preserve animation frames when both input and output are animatable formats
|
||||
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
|
||||
sharpOpts.animated = true;
|
||||
}
|
||||
const image = sharp(inputBuffer, sharpOpts);
|
||||
|
||||
let buffer: Buffer;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
|
||||
const settingsSchema = z.object({
|
||||
shadow: hexColor.default("#1e3a8a"),
|
||||
highlight: hexColor.default("#fbbf24"),
|
||||
});
|
||||
|
||||
function parseHex(hex: string) {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerDuotone(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "duotone",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const a = parseHex(settings.shadow);
|
||||
const b = parseHex(settings.highlight);
|
||||
|
||||
// Duotone math: output = shadow + (highlight - shadow) * luminance
|
||||
// .linear(multipliers, offsets) with per-channel arrays
|
||||
const multipliers = [(b.r - a.r) / 255, (b.g - a.g) / 255, (b.b - a.b) / 255];
|
||||
const offsets = [a.r, a.g, a.b];
|
||||
|
||||
// Grayscale to single channel, then expand back to 3-channel sRGB
|
||||
// so that .linear() can apply per-channel multipliers/offsets
|
||||
const grayBuf = await sharp(inputBuffer)
|
||||
.removeAlpha()
|
||||
.grayscale()
|
||||
.toColourspace("srgb")
|
||||
.toBuffer();
|
||||
|
||||
const buf = await sharp(grayBuf).linear(multipliers, offsets).toBuffer();
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_duotone.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
@@ -9,6 +12,7 @@ import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { encodeMultiIco, hasMagick } from "../../lib/format-encoders.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||
|
||||
@@ -172,8 +176,36 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
archive.append(buffer, { name: `${prefix}${icon.name}` });
|
||||
}
|
||||
|
||||
const ico32 = await sharp(file.buffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: `${prefix}favicon.ico` });
|
||||
// Generate a true multi-size ICO (16/32/48/256) via ImageMagick
|
||||
// when available; fall back to a single 32px PNG renamed .ico otherwise.
|
||||
const magickReady = await hasMagick();
|
||||
if (magickReady) {
|
||||
const icoId = randomUUID();
|
||||
const icoSizes = [16, 32, 48, 256];
|
||||
const icoPaths: string[] = [];
|
||||
const icoOutPath = join(tmpdir(), `favicon-${icoId}.ico`);
|
||||
try {
|
||||
for (const sz of icoSizes) {
|
||||
const pngPath = join(tmpdir(), `favicon-${icoId}-${sz}.png`);
|
||||
const buf = await sharp(file.buffer)
|
||||
.resize(sz, sz, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
await writeFile(pngPath, buf);
|
||||
icoPaths.push(pngPath);
|
||||
}
|
||||
await encodeMultiIco(icoPaths, icoOutPath);
|
||||
const icoData = await readFile(icoOutPath);
|
||||
archive.append(icoData, { name: `${prefix}favicon.ico` });
|
||||
} finally {
|
||||
for (const p of [...icoPaths, icoOutPath]) {
|
||||
await rm(p, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const ico32 = await sharp(file.buffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: `${prefix}favicon.ico` });
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
name: stem,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { extname } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerGifWebp(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "gif-webp",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, _settings, filename) => {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
|
||||
// Route-level extension guard: image modality has no 415 gate
|
||||
if (ext !== ".gif" && ext !== ".webp") {
|
||||
throw new InputValidationError("Only GIF and WebP inputs are supported");
|
||||
}
|
||||
|
||||
if (ext === ".gif") {
|
||||
// GIF -> WebP (preserving animation)
|
||||
const buffer = await sharp(inputBuffer, { animated: true }).webp().toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}.webp`,
|
||||
contentType: "image/webp",
|
||||
};
|
||||
}
|
||||
|
||||
// WebP -> GIF (preserving animation)
|
||||
const buffer = await sharp(inputBuffer, { animated: true }).gif().toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}.gif`,
|
||||
contentType: "image/gif",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({});
|
||||
|
||||
export function registerHistogram(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "histogram",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("histogram is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const inputBuffer = ctx.inputs[0].buffer;
|
||||
const filename = ctx.inputs[0].filename;
|
||||
|
||||
// Extract raw RGB pixel data (no alpha)
|
||||
const { data } = await sharp(inputBuffer)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
// Build 256-bin histograms per channel in a single pass
|
||||
const rBins = new Uint32Array(256);
|
||||
const gBins = new Uint32Array(256);
|
||||
const bBins = new Uint32Array(256);
|
||||
|
||||
let rSum = 0;
|
||||
let gSum = 0;
|
||||
let bSum = 0;
|
||||
const pixelCount = data.length / 3;
|
||||
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
rBins[r]++;
|
||||
gBins[g]++;
|
||||
bBins[b]++;
|
||||
rSum += r;
|
||||
gSum += g;
|
||||
bSum += b;
|
||||
}
|
||||
|
||||
// Find max bin value for normalization
|
||||
let maxBin = 0;
|
||||
let rMax = 0;
|
||||
let gMax = 0;
|
||||
let bMax = 0;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
if (rBins[i] > maxBin) maxBin = rBins[i];
|
||||
if (gBins[i] > maxBin) maxBin = gBins[i];
|
||||
if (bBins[i] > maxBin) maxBin = bBins[i];
|
||||
if (rBins[i] > rMax) rMax = rBins[i];
|
||||
if (gBins[i] > gMax) gMax = gBins[i];
|
||||
if (bBins[i] > bMax) bMax = bBins[i];
|
||||
}
|
||||
|
||||
// Render a 512x320 SVG with three semi-transparent polylines
|
||||
const svgW = 512;
|
||||
const svgH = 320;
|
||||
const scaleX = svgW / 255;
|
||||
const scaleY = maxBin > 0 ? svgH / maxBin : 1;
|
||||
|
||||
const buildPoints = (bins: Uint32Array): string => {
|
||||
const pts: string[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = Math.round(i * scaleX);
|
||||
const y = Math.round(svgH - bins[i] * scaleY);
|
||||
pts.push(`${x},${y}`);
|
||||
}
|
||||
return pts.join(" ");
|
||||
};
|
||||
|
||||
const svg = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">` +
|
||||
`<rect width="100%" height="100%" fill="#1a1a2e"/>` +
|
||||
`<polyline points="${buildPoints(rBins)}" fill="none" stroke="rgba(255,0,0,0.6)" stroke-width="1.5"/>` +
|
||||
`<polyline points="${buildPoints(gBins)}" fill="none" stroke="rgba(0,255,0,0.6)" stroke-width="1.5"/>` +
|
||||
`<polyline points="${buildPoints(bBins)}" fill="none" stroke="rgba(0,128,255,0.6)" stroke-width="1.5"/>` +
|
||||
`</svg>`,
|
||||
);
|
||||
|
||||
// Rasterize to PNG
|
||||
const buffer = await sharp(svg).png().toBuffer();
|
||||
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_histogram.png`,
|
||||
contentType: "image/png",
|
||||
resultPayload: {
|
||||
mean: {
|
||||
r: Math.round(rSum / pixelCount),
|
||||
g: Math.round(gSum / pixelCount),
|
||||
b: Math.round(bSum / pixelCount),
|
||||
},
|
||||
max: {
|
||||
r: rMax,
|
||||
g: gMax,
|
||||
b: bMax,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("1:1"),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#ffffff"),
|
||||
});
|
||||
|
||||
/** Compute canvas dimensions for the given target aspect ratio. */
|
||||
export function canvasFor(w: number, h: number, target: string): { cw: number; ch: number } {
|
||||
const [tw, th] = target.split(":").map(Number);
|
||||
const targetRatio = tw / th;
|
||||
const srcRatio = w / h;
|
||||
|
||||
let cw: number;
|
||||
let ch: number;
|
||||
|
||||
if (srcRatio > targetRatio) {
|
||||
// Image is wider than target: expand height
|
||||
cw = w;
|
||||
ch = Math.round(w / targetRatio);
|
||||
} else {
|
||||
// Image is taller than target: expand width
|
||||
ch = h;
|
||||
cw = Math.round(h * targetRatio);
|
||||
}
|
||||
|
||||
return { cw, ch };
|
||||
}
|
||||
|
||||
function parseHex(hex: string) {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerImagePad(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "image-pad",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
|
||||
const { cw, ch } = canvasFor(w, h, settings.target);
|
||||
const c = parseHex(settings.color);
|
||||
|
||||
const padTop = Math.floor((ch - h) / 2);
|
||||
const padBottom = ch - h - padTop;
|
||||
const padLeft = Math.floor((cw - w) / 2);
|
||||
const padRight = cw - w - padLeft;
|
||||
|
||||
const buf = await sharp(inputBuffer)
|
||||
.extend({
|
||||
top: padTop,
|
||||
bottom: padBottom,
|
||||
left: padLeft,
|
||||
right: padRight,
|
||||
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_padded.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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 { registerBarcodeGenerate } from "./barcode-generate.js";
|
||||
import { registerBarcodeRead } from "./barcode-read.js";
|
||||
import { registerBeautify } from "./beautify.js";
|
||||
import { registerBlurFaces } from "./blur-faces.js";
|
||||
@@ -17,6 +18,8 @@ import { registerBorder } from "./border.js";
|
||||
import { registerBulkRename } from "./bulk-rename.js";
|
||||
import { registerBurnSubtitles } from "./burn-subtitles.js";
|
||||
import { registerChangeFps } from "./change-fps.js";
|
||||
import { registerChartMaker } from "./chart-maker.js";
|
||||
import { registerCircleCrop } from "./circle-crop.js";
|
||||
import { registerCollage } from "./collage.js";
|
||||
import { registerColorBlindness } from "./color-blindness.js";
|
||||
import { registerColorPalette } from "./color-palette.js";
|
||||
@@ -39,6 +42,7 @@ import { registerCropPdf } from "./crop-pdf.js";
|
||||
import { registerCropVideo } from "./crop-video.js";
|
||||
import { registerCsvExcel } from "./csv-excel.js";
|
||||
import { registerCsvJson } from "./csv-json.js";
|
||||
import { registerDuotone } from "./duotone.js";
|
||||
import { registerEditMetadata } from "./edit-metadata.js";
|
||||
import { registerEmbedSubtitles } from "./embed-subtitles.js";
|
||||
import { registerEnhanceFaces } from "./enhance-faces.js";
|
||||
@@ -55,16 +59,20 @@ import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
import { registerFlattenPdf } from "./flatten-pdf.js";
|
||||
import { registerGifToVideo } from "./gif-to-video.js";
|
||||
import { registerGifTools } from "./gif-tools.js";
|
||||
import { registerGifWebp } from "./gif-webp.js";
|
||||
import { registerGrayscalePdf } from "./grayscale-pdf.js";
|
||||
import { registerHistogram } from "./histogram.js";
|
||||
import { registerHtmlToImage } from "./html-to-image.js";
|
||||
import { registerHtmlToPdf } from "./html-to-pdf.js";
|
||||
import { registerImageEnhancement } from "./image-enhancement.js";
|
||||
import { registerImagePad } from "./image-pad.js";
|
||||
import { registerImageToBase64 } from "./image-to-base64.js";
|
||||
import { registerImageToPdf } from "./image-to-pdf.js";
|
||||
import { registerImagesToVideo } from "./images-to-video.js";
|
||||
import { registerInfo } from "./info.js";
|
||||
import { registerJsonXml } from "./json-xml.js";
|
||||
import { registerLinearizePdf } from "./linearize-pdf.js";
|
||||
import { registerLqipPlaceholder } from "./lqip-placeholder.js";
|
||||
import { registerMarkdownToDocx } from "./markdown-to-docx.js";
|
||||
import { registerMarkdownToHtml } from "./markdown-to-html.js";
|
||||
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
|
||||
@@ -89,6 +97,7 @@ 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 { registerPixelate } from "./pixelate.js";
|
||||
import { registerPowerpointToPdf } from "./powerpoint-to-pdf.js";
|
||||
import { registerProtectPdf } from "./protect-pdf.js";
|
||||
import { registerQrGenerate } from "./qr-generate.js";
|
||||
@@ -115,6 +124,7 @@ import { registerSplit } from "./split.js";
|
||||
import { registerSplitAudio } from "./split-audio.js";
|
||||
import { registerSplitCsv } from "./split-csv.js";
|
||||
import { registerSplitPdf } from "./split-pdf.js";
|
||||
import { registerSpriteSheet } from "./sprite-sheet.js";
|
||||
import { registerStabilizeVideo } from "./stabilize-video.js";
|
||||
import { registerStitch } from "./stitch.js";
|
||||
import { registerStripMetadata } from "./strip-metadata.js";
|
||||
@@ -134,6 +144,7 @@ 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 { registerVignette } from "./vignette.js";
|
||||
import { registerVolumeAdjust } from "./volume-adjust.js";
|
||||
import { registerWatermarkImage } from "./watermark-image.js";
|
||||
import { registerWatermarkPdf } from "./watermark-pdf.js";
|
||||
@@ -201,6 +212,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "color-palette", register: registerColorPalette },
|
||||
{ id: "qr-generate", register: registerQrGenerate },
|
||||
{ id: "html-to-image", register: registerHtmlToImage },
|
||||
{ id: "barcode-generate", register: registerBarcodeGenerate },
|
||||
{ id: "barcode-read", register: registerBarcodeRead },
|
||||
{ id: "image-to-base64", register: registerImageToBase64 },
|
||||
|
||||
@@ -210,11 +222,20 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "split", register: registerSplit },
|
||||
{ id: "border", register: registerBorder },
|
||||
{ id: "beautify", register: registerBeautify },
|
||||
{ id: "circle-crop", register: registerCircleCrop },
|
||||
{ id: "duotone", register: registerDuotone },
|
||||
{ id: "histogram", register: registerHistogram },
|
||||
{ id: "image-pad", register: registerImagePad },
|
||||
{ id: "lqip-placeholder", register: registerLqipPlaceholder },
|
||||
{ id: "pixelate", register: registerPixelate },
|
||||
{ id: "sprite-sheet", register: registerSpriteSheet },
|
||||
{ id: "vignette", register: registerVignette },
|
||||
|
||||
// Format & Conversion
|
||||
{ id: "svg-to-raster", register: registerSvgToRaster },
|
||||
{ id: "vectorize", register: registerVectorize },
|
||||
{ id: "gif-tools", register: registerGifTools },
|
||||
{ id: "gif-webp", register: registerGifWebp },
|
||||
{ id: "pdf-to-image", register: registerPdfToImage },
|
||||
|
||||
// Optimization extras
|
||||
@@ -313,6 +334,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "to-epub", register: registerToEpub },
|
||||
|
||||
// Data Files
|
||||
{ id: "chart-maker", register: registerChartMaker },
|
||||
{ id: "create-zip", register: registerCreateZip },
|
||||
{ id: "csv-excel", register: registerCsvExcel },
|
||||
{ id: "csv-json", register: registerCsvJson },
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
width: z.number().int().min(4).max(64).default(16),
|
||||
blur: z.number().min(0).max(20).default(2),
|
||||
});
|
||||
|
||||
export function registerLqipPlaceholder(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "lqip-placeholder",
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("lqip-placeholder is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const inputBuffer = ctx.inputs[0].buffer;
|
||||
const filename = ctx.inputs[0].filename;
|
||||
|
||||
let pipeline = sharp(inputBuffer).resize(settings.width);
|
||||
|
||||
if (settings.blur > 0) {
|
||||
pipeline = pipeline.blur(settings.blur);
|
||||
}
|
||||
|
||||
const buffer = await pipeline.webp({ quality: 50 }).toBuffer();
|
||||
|
||||
const meta = await sharp(buffer).metadata();
|
||||
const dataUri = `data:image/webp;base64,${buffer.toString("base64")}`;
|
||||
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_lqip.webp`,
|
||||
contentType: "image/webp",
|
||||
resultPayload: {
|
||||
dataUri,
|
||||
width: meta.width ?? settings.width,
|
||||
height: meta.height ?? 0,
|
||||
bytes: buffer.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
blockSize: z.number().int().min(2).max(128).default(12),
|
||||
region: z
|
||||
.object({
|
||||
left: z.number().int().min(0),
|
||||
top: z.number().int().min(0),
|
||||
width: z.number().int().min(1),
|
||||
height: z.number().int().min(1),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function registerPixelate(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "pixelate",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
const bs = settings.blockSize;
|
||||
|
||||
let buf: Buffer;
|
||||
|
||||
if (settings.region) {
|
||||
const r = settings.region;
|
||||
// Validate region bounds
|
||||
if (r.left + r.width > w || r.top + r.height > h) {
|
||||
throw new InputValidationError("Region exceeds image bounds");
|
||||
}
|
||||
|
||||
// Extract region, pixelate it, composite back
|
||||
const rw = Math.max(1, Math.round(r.width / bs));
|
||||
const rh = Math.max(1, Math.round(r.height / bs));
|
||||
|
||||
const pixelatedRegion = await sharp(inputBuffer)
|
||||
.extract({ left: r.left, top: r.top, width: r.width, height: r.height })
|
||||
.resize(rw, rh, { kernel: sharp.kernel.nearest })
|
||||
.resize(r.width, r.height, { kernel: sharp.kernel.nearest })
|
||||
.toBuffer();
|
||||
|
||||
buf = await sharp(inputBuffer)
|
||||
.composite([{ input: pixelatedRegion, left: r.left, top: r.top }])
|
||||
.toBuffer();
|
||||
} else {
|
||||
// Full image pixelation
|
||||
const smallW = Math.max(1, Math.round(w / bs));
|
||||
const smallH = Math.max(1, Math.round(h / bs));
|
||||
|
||||
buf = await sharp(inputBuffer)
|
||||
.resize(smallW, smallH, { kernel: sharp.kernel.nearest })
|
||||
.resize(w, h, { kernel: sharp.kernel.nearest })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_pixelated.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { putObject } from "../../lib/object-storage.js";
|
||||
@@ -17,6 +18,11 @@ const settingsSchema = z.object({
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#FFFFFF"),
|
||||
logoDataUri: z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpeg);base64,[A-Za-z0-9+/=]+$/)
|
||||
.max(700000)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -43,9 +49,13 @@ export function registerQrGenerate(app: FastifyInstance) {
|
||||
const settings = result.data;
|
||||
|
||||
try {
|
||||
const buffer = await QRCode.toBuffer(settings.text, {
|
||||
// When a logo is present, force max error correction so the QR
|
||||
// remains scannable despite the logo occluding the center.
|
||||
const ecLevel = settings.logoDataUri ? "H" : settings.errorCorrection;
|
||||
|
||||
let buffer = await QRCode.toBuffer(settings.text, {
|
||||
width: settings.size,
|
||||
errorCorrectionLevel: settings.errorCorrection,
|
||||
errorCorrectionLevel: ecLevel,
|
||||
color: {
|
||||
dark: settings.foreground,
|
||||
light: settings.background,
|
||||
@@ -54,6 +64,34 @@ export function registerQrGenerate(app: FastifyInstance) {
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
if (settings.logoDataUri) {
|
||||
// Decode the data-URI base64 payload into a buffer
|
||||
const base64Part = settings.logoDataUri.split(",")[1];
|
||||
let logoBuffer: Buffer;
|
||||
try {
|
||||
logoBuffer = Buffer.from(base64Part, "base64");
|
||||
// Validate that sharp can decode it
|
||||
await sharp(logoBuffer).metadata();
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid logo image" });
|
||||
}
|
||||
|
||||
// Resize logo to 22% of QR size and composite centered
|
||||
const logoSize = Math.round(settings.size * 0.22);
|
||||
const resizedLogo = await sharp(logoBuffer)
|
||||
.resize(logoSize, logoSize, {
|
||||
fit: "contain",
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
buffer = await sharp(buffer)
|
||||
.composite([{ input: resizedLogo, gravity: "centre" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
const filename = "qrcode.png";
|
||||
await putObject(`outputs/${jobId}/${filename}`, buffer);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { InputValidationError } from "../../modality/contract.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
columns: z.number().int().min(1).max(16).default(4),
|
||||
padding: z.number().int().min(0).max(64).default(0),
|
||||
background: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#ffffff"),
|
||||
});
|
||||
|
||||
function parseHex(hex: string) {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerSpriteSheet(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "sprite-sheet",
|
||||
maxInputs: 64,
|
||||
settingsSchema,
|
||||
process: async () => {
|
||||
throw new Error("sprite-sheet is v2-only");
|
||||
},
|
||||
processV2: async (ctx) => {
|
||||
if (ctx.inputs.length < 2) {
|
||||
throw new InputValidationError("Provide at least two images");
|
||||
}
|
||||
|
||||
const settings = settingsSchema.parse(ctx.settings);
|
||||
const n = ctx.inputs.length;
|
||||
const cols = Math.min(settings.columns, n);
|
||||
const rows = Math.ceil(n / cols);
|
||||
|
||||
// Use the FIRST image's dimensions as the cell size
|
||||
const firstMeta = await sharp(ctx.inputs[0].buffer).metadata();
|
||||
const cellW = firstMeta.width ?? 1;
|
||||
const cellH = firstMeta.height ?? 1;
|
||||
|
||||
const pad = settings.padding;
|
||||
const canvasW = cols * cellW + (cols - 1) * pad;
|
||||
const canvasH = rows * cellH + (rows - 1) * pad;
|
||||
|
||||
const bg = parseHex(settings.background);
|
||||
const composites: sharp.OverlayOptions[] = [];
|
||||
const frames: Array<{
|
||||
index: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const left = col * (cellW + pad);
|
||||
const top = row * (cellH + pad);
|
||||
|
||||
// Resize each image to cover the cell size (first image keeps its size)
|
||||
let cellBuf: Buffer;
|
||||
if (i === 0) {
|
||||
cellBuf = ctx.inputs[i].buffer;
|
||||
} else {
|
||||
cellBuf = await sharp(ctx.inputs[i].buffer)
|
||||
.resize(cellW, cellH, { fit: "cover" })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
composites.push({ input: cellBuf, left, top });
|
||||
frames.push({ index: i, left, top, width: cellW, height: cellH });
|
||||
}
|
||||
|
||||
const buffer = await sharp({
|
||||
create: {
|
||||
width: canvasW,
|
||||
height: canvasH,
|
||||
channels: 4,
|
||||
background: { r: bg.r, g: bg.g, b: bg.b, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer,
|
||||
filename: "sprite.png",
|
||||
contentType: "image/png",
|
||||
resultPayload: { frames },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
strength: z.number().min(0.1).max(1).default(0.5),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#000000"),
|
||||
});
|
||||
|
||||
export function registerVignette(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "vignette",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const meta = await sharp(inputBuffer).metadata();
|
||||
const w = meta.width ?? 1;
|
||||
const h = meta.height ?? 1;
|
||||
|
||||
// Build radial-gradient SVG overlay
|
||||
const svg = Buffer.from(
|
||||
`<svg width="${w}" height="${h}">` +
|
||||
`<defs><radialGradient id="v" cx="50%" cy="50%" r="70%">` +
|
||||
`<stop offset="50%" stop-color="${settings.color}" stop-opacity="0"/>` +
|
||||
`<stop offset="100%" stop-color="${settings.color}" stop-opacity="${settings.strength}"/>` +
|
||||
`</radialGradient></defs>` +
|
||||
`<rect width="100%" height="100%" fill="url(#v)"/>` +
|
||||
`</svg>`,
|
||||
);
|
||||
|
||||
const overlay = await sharp(svg).resize(w, h).toBuffer();
|
||||
|
||||
const buf = await sharp(inputBuffer)
|
||||
.composite([{ input: overlay, blend: "over" }])
|
||||
.toBuffer();
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await sharp(buf)
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
const base = filename.replace(/\.[^.]+$/, "");
|
||||
const ext = outputFormat.extension;
|
||||
return {
|
||||
buffer,
|
||||
filename: `${base}_vignette.${ext}`,
|
||||
contentType: outputFormat.contentType,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
const BARCODE_TYPES = [
|
||||
{ value: "code128", label: "Code 128" },
|
||||
{ value: "ean13", label: "EAN-13" },
|
||||
{ value: "upca", label: "UPC-A" },
|
||||
{ value: "code39", label: "Code 39" },
|
||||
{ value: "itf14", label: "ITF-14" },
|
||||
{ value: "datamatrix", label: "Data Matrix" },
|
||||
] as const;
|
||||
|
||||
const INPUT_CLASS =
|
||||
"w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground";
|
||||
|
||||
export function BarcodeGenerateSettings() {
|
||||
const { t } = useTranslation();
|
||||
const ts = t.toolSettings["barcode-generate"];
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const [type, setType] = useState("code128");
|
||||
const [scale, setScale] = useState(3);
|
||||
const [includeText, setIncludeText] = useState(true);
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||||
|
||||
const canGenerate = text.trim().length > 0 && !generating;
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
setResultUrl(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/tools/barcode-generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...formatHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text: text.trim(), type, scale, includeText }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.downloadUrl) {
|
||||
setResultUrl(data.downloadUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate barcode");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [text, type, scale, includeText]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canGenerate) handleGenerate();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Text input */}
|
||||
<div>
|
||||
<label htmlFor="barcode-text" className="text-xs text-muted-foreground">
|
||||
{ts.text}
|
||||
</label>
|
||||
<input
|
||||
id="barcode-text"
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Enter text or number..."
|
||||
className={INPUT_CLASS}
|
||||
data-testid="barcode-input-text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Barcode Type */}
|
||||
<div>
|
||||
<label htmlFor="barcode-type" className="text-xs text-muted-foreground">
|
||||
{ts.type}
|
||||
</label>
|
||||
<select
|
||||
id="barcode-type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{BARCODE_TYPES.map((bt) => (
|
||||
<option key={bt.value} value={bt.value}>
|
||||
{bt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Scale */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="barcode-scale" className="text-xs text-muted-foreground">
|
||||
{ts.scale}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{scale}x</span>
|
||||
</div>
|
||||
<input
|
||||
id="barcode-scale"
|
||||
type="range"
|
||||
min={1}
|
||||
max={8}
|
||||
value={scale}
|
||||
onChange={(e) => setScale(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Include Text */}
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeText}
|
||||
onChange={(e) => setIncludeText(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{ts.includeText}
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Result preview */}
|
||||
{resultUrl && (
|
||||
<div className="space-y-2">
|
||||
<img
|
||||
src={resultUrl}
|
||||
alt="Generated barcode"
|
||||
className="w-full rounded border border-border bg-white p-2"
|
||||
/>
|
||||
<a
|
||||
href={resultUrl}
|
||||
download="barcode.png"
|
||||
data-testid="barcode-generate-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{ts.download}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="barcode-generate-submit"
|
||||
disabled={!canGenerate}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{ts.submit}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const CHART_KINDS = [
|
||||
{ value: "bar", label: "Bar" },
|
||||
{ value: "line", label: "Line" },
|
||||
{ value: "pie", label: "Pie" },
|
||||
] as const;
|
||||
|
||||
const INPUT_CLASS =
|
||||
"w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground";
|
||||
|
||||
export function ChartMakerSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("chart-maker");
|
||||
|
||||
const [kind, setKind] = useState("bar");
|
||||
const [title, setTitle] = useState("");
|
||||
const [width, setWidth] = useState(960);
|
||||
const [height, setHeight] = useState(540);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { kind, width, height };
|
||||
if (title.trim()) settings.title = title.trim();
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Chart Type */}
|
||||
<div>
|
||||
<label htmlFor="chart-kind" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["chart-maker"].kind}
|
||||
</label>
|
||||
<select
|
||||
id="chart-kind"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{CHART_KINDS.map((ck) => (
|
||||
<option key={ck.value} value={ck.value}>
|
||||
{ck.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="chart-title" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["chart-maker"].title}
|
||||
</label>
|
||||
<input
|
||||
id="chart-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Optional chart title"
|
||||
maxLength={120}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Width */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="chart-width" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["chart-maker"].width}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{width}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="chart-width"
|
||||
type="range"
|
||||
min={320}
|
||||
max={2048}
|
||||
step={10}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="chart-height" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["chart-maker"].height}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{height}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="chart-height"
|
||||
type="range"
|
||||
min={240}
|
||||
max={1536}
|
||||
step={10}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings["chart-maker"].progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="chart-maker-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings["chart-maker"].submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings["chart-maker"].submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="chart-maker-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function CircleCropSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("circle-crop");
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, {});
|
||||
} else {
|
||||
processFiles(files, {});
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Crops the image to a centered circle with transparent corners. Output is always PNG.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings["circle-crop"].progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="circle-crop-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings["circle-crop"].submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings["circle-crop"].submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="circle-crop-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ const OUTPUT_FORMATS = [
|
||||
"ico",
|
||||
"jp2",
|
||||
"qoi",
|
||||
"ppm",
|
||||
"eps",
|
||||
"tga",
|
||||
] as const;
|
||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl", "jp2"];
|
||||
|
||||
@@ -37,6 +40,9 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||
ico: "ICO",
|
||||
jp2: "JP2",
|
||||
qoi: "QOI",
|
||||
ppm: "PPM",
|
||||
eps: "EPS",
|
||||
tga: "TGA",
|
||||
};
|
||||
|
||||
export interface ConvertControlsProps {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function DuotoneSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("duotone");
|
||||
|
||||
const [shadow, setShadow] = useState("#1e3a8a");
|
||||
const [highlight, setHighlight] = useState("#fbbf24");
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { shadow, highlight };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Shadow Color */}
|
||||
<div>
|
||||
<label htmlFor="duotone-shadow" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings.duotone.shadow}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="duotone-shadow"
|
||||
type="color"
|
||||
value={shadow}
|
||||
onChange={(e) => setShadow(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={shadow}
|
||||
onChange={(e) => setShadow(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Highlight Color */}
|
||||
<div>
|
||||
<label htmlFor="duotone-highlight" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings.duotone.highlight}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="duotone-highlight"
|
||||
type="color"
|
||||
value={highlight}
|
||||
onChange={(e) => setHighlight(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={highlight}
|
||||
onChange={(e) => setHighlight(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings.duotone.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="duotone-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings.duotone.submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings.duotone.submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="duotone-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function GifWebpSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("gif-webp");
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, {});
|
||||
} else {
|
||||
processFiles(files, {});
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Converts GIF to WebP and WebP to GIF, preserving all animation frames. Direction is
|
||||
determined automatically by the input file format.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings["gif-webp"].progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="gif-webp-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings["gif-webp"].submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings["gif-webp"].submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="gif-webp-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function HistogramSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("histogram");
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, {});
|
||||
} else {
|
||||
processFiles(files, {});
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generates an RGB histogram chart showing the color distribution of the image.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings.histogram.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="histogram-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings.histogram.submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings.histogram.submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="histogram-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const TARGET_OPTIONS = [
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
{ value: "4:3", label: "4:3" },
|
||||
{ value: "3:4", label: "3:4" },
|
||||
] as const;
|
||||
|
||||
export function ImagePadSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("image-pad");
|
||||
|
||||
const [target, setTarget] = useState("1:1");
|
||||
const [color, setColor] = useState("#ffffff");
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { target, color };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Target Ratio */}
|
||||
<div>
|
||||
<label htmlFor="image-pad-target" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["image-pad"].target}
|
||||
</label>
|
||||
<select
|
||||
id="image-pad-target"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{TARGET_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Background Color */}
|
||||
<div>
|
||||
<label htmlFor="image-pad-color" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["image-pad"].color}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="image-pad-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings["image-pad"].progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="image-pad-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings["image-pad"].submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings["image-pad"].submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="image-pad-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function LqipPlaceholderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("lqip-placeholder");
|
||||
|
||||
const [width, setWidth] = useState(16);
|
||||
const [blur, setBlur] = useState(2);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { width, blur };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Width */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="lqip-width" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["lqip-placeholder"].width}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{width}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="lqip-width"
|
||||
type="range"
|
||||
min={4}
|
||||
max={64}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blur */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="lqip-blur" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["lqip-placeholder"].blur}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{blur}</span>
|
||||
</div>
|
||||
<input
|
||||
id="lqip-blur"
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={blur}
|
||||
onChange={(e) => setBlur(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
The base64 data URI will appear in the result envelope.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings["lqip-placeholder"].progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="lqip-placeholder-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings["lqip-placeholder"].submitBatch.replace(
|
||||
"{count}",
|
||||
String(files.length),
|
||||
)
|
||||
: t.toolSettings["lqip-placeholder"].submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="lqip-placeholder-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function PixelateSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("pixelate");
|
||||
|
||||
const [blockSize, setBlockSize] = useState(12);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { blockSize };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Block Size */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="pixelate-block-size" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings.pixelate.blockSize}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{blockSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="pixelate-block-size"
|
||||
type="range"
|
||||
min={2}
|
||||
max={128}
|
||||
value={blockSize}
|
||||
onChange={(e) => setBlockSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Applies pixelation to the full image
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings.pixelate.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="pixelate-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings.pixelate.submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings.pixelate.submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="pixelate-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -668,10 +668,11 @@ export function QrGenerateSettings() {
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/*,.avif,.heic,.heif,.hif"
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={handleLogoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
{store.logoError && <p className="text-xs text-red-500">{store.logoError}</p>}
|
||||
{store.logoDataUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function SpriteSheetSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("sprite-sheet");
|
||||
|
||||
const [columns, setColumns] = useState(4);
|
||||
const [padding, setPadding] = useState(0);
|
||||
const [background, setBackground] = useState("#ffffff");
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { columns, padding, background };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Columns */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="sprite-columns" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["sprite-sheet"].columns}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{columns}</span>
|
||||
</div>
|
||||
<input
|
||||
id="sprite-columns"
|
||||
type="range"
|
||||
min={1}
|
||||
max={16}
|
||||
value={columns}
|
||||
onChange={(e) => setColumns(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Padding */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="sprite-padding" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["sprite-sheet"].padding}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{padding}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="sprite-padding"
|
||||
type="range"
|
||||
min={0}
|
||||
max={64}
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Background Color */}
|
||||
<div>
|
||||
<label htmlFor="sprite-background" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["sprite-sheet"].background}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="sprite-background"
|
||||
type="color"
|
||||
value={background}
|
||||
onChange={(e) => setBackground(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={background}
|
||||
onChange={(e) => setBackground(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings["sprite-sheet"].progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="sprite-sheet-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{t.toolSettings["sprite-sheet"].submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="sprite-sheet-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function VignetteSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("vignette");
|
||||
|
||||
const [strength, setStrength] = useState(0.5);
|
||||
const [color, setColor] = useState("#000000");
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { strength, color };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Strength */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="vignette-strength" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings.vignette.strength}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{strength.toFixed(2)}</span>
|
||||
</div>
|
||||
<input
|
||||
id="vignette-strength"
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={strength}
|
||||
onChange={(e) => setStrength(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vignette Color */}
|
||||
<div>
|
||||
<label htmlFor="vignette-color" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings.vignette.color}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="vignette-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label={t.toolSettings.vignette.progressLabel}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="vignette-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings.vignette.submitBatch.replace("{count}", String(files.length))
|
||||
: t.toolSettings.vignette.submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="vignette-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,13 @@ import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Anchor,
|
||||
Aperture,
|
||||
AppWindow,
|
||||
Archive,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
BarChart3,
|
||||
Barcode,
|
||||
BellRing,
|
||||
Book,
|
||||
BookImage,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
Braces,
|
||||
Captions,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Code,
|
||||
Columns2,
|
||||
Combine,
|
||||
@@ -47,9 +51,11 @@ import {
|
||||
GalleryThumbnails,
|
||||
Gauge,
|
||||
Globe,
|
||||
Grid2x2,
|
||||
Grid3x3,
|
||||
Hash,
|
||||
Image,
|
||||
ImageDown,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Info,
|
||||
@@ -72,6 +78,7 @@ import {
|
||||
Presentation,
|
||||
QrCode,
|
||||
RectangleHorizontal,
|
||||
Repeat,
|
||||
Rewind,
|
||||
RotateCw,
|
||||
Scaling,
|
||||
@@ -108,10 +115,13 @@ import {
|
||||
export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Activity,
|
||||
Anchor,
|
||||
Aperture,
|
||||
AppWindow,
|
||||
Archive,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
Barcode,
|
||||
BarChart3,
|
||||
BellRing,
|
||||
Book,
|
||||
BookImage,
|
||||
@@ -119,6 +129,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Braces,
|
||||
Captions,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Code,
|
||||
Columns2,
|
||||
Combine,
|
||||
@@ -153,9 +164,11 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
GalleryThumbnails,
|
||||
Gauge,
|
||||
Globe,
|
||||
Grid2x2,
|
||||
Grid3x3,
|
||||
Hash,
|
||||
Image,
|
||||
ImageDown,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Info,
|
||||
@@ -203,6 +216,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Wand,
|
||||
Waves,
|
||||
Wrench,
|
||||
Repeat,
|
||||
Zap,
|
||||
ZoomIn,
|
||||
Layers2,
|
||||
|
||||
@@ -48,6 +48,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
"qr-generate": "no-dropzone",
|
||||
"html-to-image": "no-dropzone",
|
||||
"barcode-read": "before-after",
|
||||
"barcode-generate": "no-dropzone",
|
||||
"image-to-base64": "custom-results",
|
||||
|
||||
// Layout & composition
|
||||
@@ -56,6 +57,16 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
split: "interactive-split",
|
||||
border: "live-preview",
|
||||
beautify: "live-preview",
|
||||
"circle-crop": "live-preview",
|
||||
duotone: "live-preview",
|
||||
"image-pad": "live-preview",
|
||||
pixelate: "live-preview",
|
||||
vignette: "live-preview",
|
||||
|
||||
"gif-webp": "live-preview",
|
||||
histogram: "no-comparison",
|
||||
"lqip-placeholder": "no-comparison",
|
||||
"sprite-sheet": "no-comparison",
|
||||
|
||||
// Format & conversion
|
||||
"svg-to-raster": "before-after",
|
||||
@@ -177,6 +188,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
|
||||
"to-epub": "no-comparison",
|
||||
|
||||
// Data tools
|
||||
"chart-maker": "no-comparison",
|
||||
"csv-excel": "no-comparison",
|
||||
"csv-json": "no-comparison",
|
||||
"json-xml": "no-comparison",
|
||||
@@ -202,4 +214,5 @@ export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set([
|
||||
"burn-subtitles",
|
||||
"embed-subtitles",
|
||||
"images-to-video",
|
||||
"sprite-sheet",
|
||||
]);
|
||||
|
||||
@@ -785,6 +785,63 @@ const ExtractZipSettings = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
// Wave 5a: Image gap-fill tools
|
||||
const BarcodeGenerateSettings = lazy(() =>
|
||||
import("@/components/tools/barcode-generate-settings").then((m) => ({
|
||||
default: m.BarcodeGenerateSettings,
|
||||
})),
|
||||
);
|
||||
const ChartMakerSettings = lazy(() =>
|
||||
import("@/components/tools/chart-maker-settings").then((m) => ({
|
||||
default: m.ChartMakerSettings,
|
||||
})),
|
||||
);
|
||||
const CircleCropSettings = lazy(() =>
|
||||
import("@/components/tools/circle-crop-settings").then((m) => ({
|
||||
default: m.CircleCropSettings,
|
||||
})),
|
||||
);
|
||||
const DuotoneSettings = lazy(() =>
|
||||
import("@/components/tools/duotone-settings").then((m) => ({
|
||||
default: m.DuotoneSettings,
|
||||
})),
|
||||
);
|
||||
const GifWebpSettings = lazy(() =>
|
||||
import("@/components/tools/gif-webp-settings").then((m) => ({
|
||||
default: m.GifWebpSettings,
|
||||
})),
|
||||
);
|
||||
const HistogramSettings = lazy(() =>
|
||||
import("@/components/tools/histogram-settings").then((m) => ({
|
||||
default: m.HistogramSettings,
|
||||
})),
|
||||
);
|
||||
const ImagePadSettings = lazy(() =>
|
||||
import("@/components/tools/image-pad-settings").then((m) => ({
|
||||
default: m.ImagePadSettings,
|
||||
})),
|
||||
);
|
||||
const LqipPlaceholderSettings = lazy(() =>
|
||||
import("@/components/tools/lqip-placeholder-settings").then((m) => ({
|
||||
default: m.LqipPlaceholderSettings,
|
||||
})),
|
||||
);
|
||||
const PixelateSettings = lazy(() =>
|
||||
import("@/components/tools/pixelate-settings").then((m) => ({
|
||||
default: m.PixelateSettings,
|
||||
})),
|
||||
);
|
||||
const SpriteSheetSettings = lazy(() =>
|
||||
import("@/components/tools/sprite-sheet-settings").then((m) => ({
|
||||
default: m.SpriteSheetSettings,
|
||||
})),
|
||||
);
|
||||
const VignetteSettings = lazy(() =>
|
||||
import("@/components/tools/vignette-settings").then((m) => ({
|
||||
default: m.VignetteSettings,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── Color tool wrapper ─────────────────────────────────────────────
|
||||
// Color tools share a single component but differ by toolId.
|
||||
|
||||
@@ -851,6 +908,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["qr-generate", { Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview }],
|
||||
["html-to-image", { Settings: HtmlToImageSettings, ResultsPanel: HtmlToImageResults }],
|
||||
["barcode-read", { Settings: BarcodeReadSettings }],
|
||||
["barcode-generate", { Settings: BarcodeGenerateSettings }],
|
||||
["image-to-base64", { Settings: ImageToBase64Settings, ResultsPanel: ImageToBase64Results }],
|
||||
|
||||
// Layout & Composition
|
||||
@@ -859,6 +917,17 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["split", { Settings: SplitSettings, ResultsPanel: SplitCanvas }],
|
||||
["border", { livePreview: true, Settings: BorderSettings as never }],
|
||||
["beautify", { livePreview: true, Settings: BeautifySettings as never }],
|
||||
["circle-crop", { livePreview: true, Settings: CircleCropSettings }],
|
||||
["duotone", { livePreview: true, Settings: DuotoneSettings }],
|
||||
["image-pad", { livePreview: true, Settings: ImagePadSettings }],
|
||||
["pixelate", { livePreview: true, Settings: PixelateSettings }],
|
||||
["sprite-sheet", { Settings: SpriteSheetSettings }],
|
||||
["vignette", { livePreview: true, Settings: VignetteSettings }],
|
||||
|
||||
// Image utilities (wave 5a)
|
||||
["gif-webp", { livePreview: true, accept: ".gif,.webp", Settings: GifWebpSettings }],
|
||||
["histogram", { Settings: HistogramSettings }],
|
||||
["lqip-placeholder", { Settings: LqipPlaceholderSettings }],
|
||||
|
||||
// Format & Conversion
|
||||
["svg-to-raster", { accept: ".svg,.svgz", Settings: SvgToRasterSettings }],
|
||||
@@ -1007,6 +1076,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
|
||||
["markdown-to-pdf", { accept: ".md,.markdown", Settings: MarkdownToPdfSettings }],
|
||||
|
||||
// Data tools
|
||||
["chart-maker", { accept: ".csv,.json", Settings: ChartMakerSettings }],
|
||||
["csv-excel", { accept: ".csv,.tsv,.xlsx", Settings: CsvExcelSettings }],
|
||||
["csv-json", { accept: ".csv,.tsv,.json", Settings: CsvJsonSettings }],
|
||||
["json-xml", { accept: ".json,.xml", Settings: JsonXmlSettings }],
|
||||
|
||||
@@ -67,6 +67,7 @@ interface QrState {
|
||||
// Logo
|
||||
logoFile: File | null;
|
||||
logoDataUrl: string | null;
|
||||
logoError: string | null;
|
||||
logoSize: number;
|
||||
logoMargin: number;
|
||||
hideBackgroundDots: boolean;
|
||||
@@ -150,6 +151,7 @@ const DEFAULTS = {
|
||||
useCustomCornerColors: false,
|
||||
logoFile: null as File | null,
|
||||
logoDataUrl: null as string | null,
|
||||
logoError: null as string | null,
|
||||
logoSize: 0.4,
|
||||
logoMargin: 5,
|
||||
hideBackgroundDots: true,
|
||||
@@ -184,11 +186,22 @@ export const useQrStore = create<QrState>((set) => ({
|
||||
setUseCustomCornerColors: (v) => set({ useCustomCornerColors: v }),
|
||||
setLogoFile: (f) => {
|
||||
if (!f) {
|
||||
set({ logoFile: null, logoDataUrl: null });
|
||||
set({ logoFile: null, logoDataUrl: null, logoError: null });
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => set({ logoFile: f, logoDataUrl: reader.result as string });
|
||||
reader.onload = () => {
|
||||
const dataUri = reader.result as string;
|
||||
if (dataUri.length > 700000) {
|
||||
set({
|
||||
logoFile: null,
|
||||
logoDataUrl: null,
|
||||
logoError: "Logo image is too large (max ~500 KB). Please use a smaller image.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
set({ logoFile: f, logoDataUrl: dataUri, logoError: null });
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
},
|
||||
setLogoSize: (v) => set({ logoSize: v }),
|
||||
|
||||
+3
-3
@@ -238,11 +238,11 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $(
|
||||
fi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Allow ImageMagick to use Ghostscript delegate for EPS decode (read-only).
|
||||
# Modern Debian blocks EPS/PS by default. We only grant read rights.
|
||||
# Allow ImageMagick to use Ghostscript delegate for EPS (read for decode,
|
||||
# write for the convert tool's EPS output). PS/PDF/XPS stay read-only.
|
||||
RUN POLICY_FILE=$(find /etc/ImageMagick* -name policy.xml 2>/dev/null | head -1) && \
|
||||
if [ -n "$POLICY_FILE" ]; then \
|
||||
sed -i 's/<policy domain="coder" rights="none" pattern="EPS"/<policy domain="coder" rights="read" pattern="EPS"/' "$POLICY_FILE" && \
|
||||
sed -i 's/<policy domain="coder" rights="none" pattern="EPS"/<policy domain="coder" rights="read|write" pattern="EPS"/' "$POLICY_FILE" && \
|
||||
sed -i 's/<policy domain="coder" rights="none" pattern="PS"/<policy domain="coder" rights="read" pattern="PS"/' "$POLICY_FILE" && \
|
||||
sed -i 's/<policy domain="coder" rights="none" pattern="PDF"/<policy domain="coder" rights="read" pattern="PDF"/' "$POLICY_FILE" && \
|
||||
sed -i 's/<policy domain="coder" rights="none" pattern="XPS"/<policy domain="coder" rights="read" pattern="XPS"/' "$POLICY_FILE"; \
|
||||
|
||||
@@ -508,6 +508,17 @@ export const TOOLS: Tool[] = [
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "barcode-generate",
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
category: "utilities",
|
||||
icon: "Barcode",
|
||||
route: "/barcode-generate",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Layout & Composition
|
||||
{
|
||||
id: "collage",
|
||||
@@ -564,6 +575,105 @@ export const TOOLS: Tool[] = [
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "circle-crop",
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
category: "layout",
|
||||
icon: "Circle",
|
||||
route: "/circle-crop",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "duotone",
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
category: "adjustments",
|
||||
icon: "Palette",
|
||||
route: "/duotone",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "image-pad",
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
category: "layout",
|
||||
icon: "RectangleHorizontal",
|
||||
route: "/image-pad",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "pixelate",
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
category: "adjustments",
|
||||
icon: "Grid2x2",
|
||||
route: "/pixelate",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "vignette",
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
category: "adjustments",
|
||||
icon: "Aperture",
|
||||
route: "/vignette",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "gif-webp",
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
category: "format",
|
||||
icon: "Repeat",
|
||||
route: "/gif-webp",
|
||||
modality: "image",
|
||||
acceptedInputs: [".gif", ".webp"],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "histogram",
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
category: "utilities",
|
||||
icon: "BarChart3",
|
||||
route: "/histogram",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "lqip-placeholder",
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
category: "utilities",
|
||||
icon: "ImageDown",
|
||||
route: "/lqip-placeholder",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "sprite-sheet",
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
category: "layout",
|
||||
icon: "Grid3x3",
|
||||
route: "/sprite-sheet",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Format & Conversion
|
||||
{
|
||||
id: "svg-to-raster",
|
||||
@@ -1487,6 +1597,17 @@ export const TOOLS: Tool[] = [
|
||||
executionHint: "long",
|
||||
},
|
||||
// Data Files
|
||||
{
|
||||
id: "chart-maker",
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
category: "data",
|
||||
icon: "BarChart3",
|
||||
route: "/chart-maker",
|
||||
modality: "file",
|
||||
acceptedInputs: [".csv", ".json"],
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
id: "csv-excel",
|
||||
name: "CSV to Excel",
|
||||
|
||||
@@ -258,6 +258,42 @@ export const ar: TranslationKeys = {
|
||||
name: "تجميل لقطة الشاشة",
|
||||
description: "إضافة خلفيات متدرجة وإطارات أجهزة وظلال وأحجام وسائل التواصل للقطات الشاشة",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG إلى صورة نقطية",
|
||||
description:
|
||||
@@ -276,6 +312,14 @@ export const ar: TranslationKeys = {
|
||||
name: "صورة إلى Base64",
|
||||
description: "تحويل الصور إلى سلاسل Base64 لتضمينها في HTML و CSS والمزيد",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1505,6 +1549,63 @@ export const ar: TranslationKeys = {
|
||||
submitBatch: "تجميل ({count} ملف)",
|
||||
progressLabel: "جاري تجميل لقطة الشاشة",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "صيغة الإخراج",
|
||||
sizing: "التحجيم",
|
||||
@@ -1586,6 +1687,23 @@ export const ar: TranslationKeys = {
|
||||
copyImgTag: "نسخ وسم <img>",
|
||||
copyCssUrl: "نسخ CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "النموذج",
|
||||
fast: "سريع",
|
||||
|
||||
@@ -262,6 +262,42 @@ export const de: TranslationKeys = {
|
||||
description:
|
||||
"Verlaufshintergruende, Geraeterahmen, Schatten und Social-Media-Formate fuer Screenshots",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG zu Raster",
|
||||
description:
|
||||
@@ -281,6 +317,14 @@ export const de: TranslationKeys = {
|
||||
description:
|
||||
"Bilder in Base64-Zeichenketten zum Einbetten in HTML, CSS und mehr konvertieren",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1518,6 +1562,63 @@ export const de: TranslationKeys = {
|
||||
submitBatch: "Verschoenern ({count} Dateien)",
|
||||
progressLabel: "Screenshot wird verschoenert",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Ausgabeformat",
|
||||
sizing: "Groesse",
|
||||
@@ -1599,6 +1700,23 @@ export const de: TranslationKeys = {
|
||||
copyImgTag: "<img>-Tag kopieren",
|
||||
copyCssUrl: "CSS url() kopieren",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Modell",
|
||||
fast: "Schnell",
|
||||
|
||||
@@ -218,6 +218,42 @@ export const en = {
|
||||
description:
|
||||
"Add gradient backgrounds, device frames, shadows, and social media sizing to screenshots",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG to Raster",
|
||||
description:
|
||||
@@ -236,6 +272,14 @@ export const en = {
|
||||
name: "Image to Base64",
|
||||
description: "Convert images to base64 strings for embedding in HTML, CSS, and more",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1463,6 +1507,63 @@ export const en = {
|
||||
submitBatch: "Beautify ({count} files)",
|
||||
progressLabel: "Beautifying screenshot",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Output Format",
|
||||
sizing: "Sizing",
|
||||
@@ -1544,6 +1645,23 @@ export const en = {
|
||||
copyImgTag: "Copy <img> Tag",
|
||||
copyCssUrl: "Copy CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Model",
|
||||
fast: "Fast",
|
||||
|
||||
@@ -248,6 +248,42 @@ export const es: TranslationKeys = {
|
||||
description:
|
||||
"Agrega fondos degradados, marcos de dispositivo, sombras y tamanos de redes sociales a capturas de pantalla",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG a raster",
|
||||
description:
|
||||
@@ -266,6 +302,14 @@ export const es: TranslationKeys = {
|
||||
name: "Imagen a Base64",
|
||||
description: "Convierte imagenes a cadenas Base64 para incrustar en HTML, CSS y mas",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1500,6 +1544,63 @@ export const es: TranslationKeys = {
|
||||
submitBatch: "Embellecer ({count} archivos)",
|
||||
progressLabel: "Embelleciendo captura de pantalla",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Formato de salida",
|
||||
sizing: "Dimensionado",
|
||||
@@ -1581,6 +1682,23 @@ export const es: TranslationKeys = {
|
||||
copyImgTag: "Copiar etiqueta <img>",
|
||||
copyCssUrl: "Copiar CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Modelo",
|
||||
fast: "Rapido",
|
||||
|
||||
@@ -263,6 +263,42 @@ export const fr: TranslationKeys = {
|
||||
description:
|
||||
"Ajoutez des fonds degrades, des cadres d'appareils, des ombres et des tailles pour reseaux sociaux aux captures d'ecran",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG vers raster",
|
||||
description:
|
||||
@@ -282,6 +318,14 @@ export const fr: TranslationKeys = {
|
||||
description:
|
||||
"Convertissez des images en chaines Base64 pour les integrer dans HTML, CSS et plus",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1519,6 +1563,63 @@ export const fr: TranslationKeys = {
|
||||
submitBatch: "Embellir ({count} fichiers)",
|
||||
progressLabel: "Embellissement de la capture d'ecran",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Format de sortie",
|
||||
sizing: "Dimensionnement",
|
||||
@@ -1600,6 +1701,23 @@ export const fr: TranslationKeys = {
|
||||
copyImgTag: "Copier la balise <img>",
|
||||
copyCssUrl: "Copier CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Modele",
|
||||
fast: "Rapide",
|
||||
|
||||
@@ -257,6 +257,42 @@ export const hi: TranslationKeys = {
|
||||
name: "स्क्रीनशॉट ब्यूटीफाई",
|
||||
description: "स्क्रीनशॉट में ग्रेडिएंट बैकग्राउंड, डिवाइस फ्रेम, शैडो और सोशल मीडिया साइज़िंग जोड़ें",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG से रास्टर",
|
||||
description: "SVG को कस्टम स्केल और DPI पर PNG, JPEG, WebP, AVIF, TIFF, GIF या HEIF में कन्वर्ट करें",
|
||||
@@ -273,6 +309,14 @@ export const hi: TranslationKeys = {
|
||||
name: "इमेज से Base64",
|
||||
description: "HTML, CSS और अन्य में एम्बेड करने के लिए इमेज को Base64 स्ट्रिंग में कन्वर्ट करें",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1501,6 +1545,63 @@ export const hi: TranslationKeys = {
|
||||
submitBatch: "ब्यूटीफाई ({count} फाइलें)",
|
||||
progressLabel: "स्क्रीनशॉट ब्यूटीफाई हो रहा है",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "आउटपुट फॉर्मेट",
|
||||
sizing: "साइज़िंग",
|
||||
@@ -1582,6 +1683,23 @@ export const hi: TranslationKeys = {
|
||||
copyImgTag: "<img> टैग कॉपी करें",
|
||||
copyCssUrl: "CSS url() कॉपी करें",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "मॉडल",
|
||||
fast: "तेज़",
|
||||
|
||||
@@ -263,6 +263,42 @@ export const id: TranslationKeys = {
|
||||
description:
|
||||
"Tambahkan latar gradien, bingkai perangkat, bayangan, dan ukuran media sosial ke screenshot",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG ke Raster",
|
||||
description:
|
||||
@@ -281,6 +317,14 @@ export const id: TranslationKeys = {
|
||||
name: "Gambar ke Base64",
|
||||
description: "Konversi gambar ke string Base64 untuk disematkan di HTML, CSS, dan lainnya",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1513,6 +1557,63 @@ export const id: TranslationKeys = {
|
||||
submitBatch: "Percantik ({count} file)",
|
||||
progressLabel: "Mempercantik screenshot",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Format Output",
|
||||
sizing: "Ukuran",
|
||||
@@ -1594,6 +1695,23 @@ export const id: TranslationKeys = {
|
||||
copyImgTag: "Salin Tag <img>",
|
||||
copyCssUrl: "Salin CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Model",
|
||||
fast: "Cepat",
|
||||
|
||||
@@ -262,6 +262,42 @@ export const it: TranslationKeys = {
|
||||
description:
|
||||
"Aggiungi sfondi sfumati, cornici per dispositivi, ombre e dimensioni per social media agli screenshot",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG a raster",
|
||||
description:
|
||||
@@ -280,6 +316,14 @@ export const it: TranslationKeys = {
|
||||
name: "Immagine a Base64",
|
||||
description: "Converti immagini in stringhe Base64 per incorporarle in HTML, CSS e altro",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1512,6 +1556,63 @@ export const it: TranslationKeys = {
|
||||
submitBatch: "Abbellisci ({count} file)",
|
||||
progressLabel: "Abbellimento screenshot",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Formato di output",
|
||||
sizing: "Dimensionamento",
|
||||
@@ -1593,6 +1694,23 @@ export const it: TranslationKeys = {
|
||||
copyImgTag: "Copia tag <img>",
|
||||
copyCssUrl: "Copia CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Modello",
|
||||
fast: "Veloce",
|
||||
|
||||
@@ -228,6 +228,42 @@ export const ja: TranslationKeys = {
|
||||
description:
|
||||
"スクリーンショットにグラデーション背景、デバイスフレーム、シャドウ、SNSサイズを追加",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVGからラスター",
|
||||
description: "SVGをPNG、JPEG、WebP、AVIF、TIFF、GIF、HEIFにカスタムスケール・DPIで変換",
|
||||
@@ -244,6 +280,14 @@ export const ja: TranslationKeys = {
|
||||
name: "画像からBase64",
|
||||
description: "画像をBase64文字列に変換してHTML、CSS等に埋め込み",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1471,6 +1515,63 @@ export const ja: TranslationKeys = {
|
||||
submitBatch: "美化({count}ファイル)",
|
||||
progressLabel: "スクリーンショットを美化中",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "出力フォーマット",
|
||||
sizing: "サイズ設定",
|
||||
@@ -1552,6 +1653,23 @@ export const ja: TranslationKeys = {
|
||||
copyImgTag: "<img>タグをコピー",
|
||||
copyCssUrl: "CSS url()をコピー",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "モデル",
|
||||
fast: "高速",
|
||||
|
||||
@@ -215,6 +215,42 @@ export const ko: TranslationKeys = {
|
||||
name: "스크린샷 꾸미기",
|
||||
description: "스크린샷에 그라데이션 배경, 기기 프레임, 그림자, 소셜 미디어 크기 추가",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG를 래스터로",
|
||||
description: "SVG를 PNG, JPEG, WebP, AVIF, TIFF, GIF, HEIF로 맞춤 스케일 및 DPI로 변환",
|
||||
@@ -231,6 +267,14 @@ export const ko: TranslationKeys = {
|
||||
name: "이미지를 Base64로",
|
||||
description: "이미지를 Base64 문자열로 변환하여 HTML, CSS 등에 임베드",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1456,6 +1500,63 @@ export const ko: TranslationKeys = {
|
||||
submitBatch: "꾸미기 ({count}개 파일)",
|
||||
progressLabel: "스크린샷 꾸미는 중",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "출력 포맷",
|
||||
sizing: "크기 설정",
|
||||
@@ -1537,6 +1638,23 @@ export const ko: TranslationKeys = {
|
||||
copyImgTag: "<img> 태그 복사",
|
||||
copyCssUrl: "CSS url() 복사",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "모델",
|
||||
fast: "빠름",
|
||||
|
||||
@@ -263,6 +263,42 @@ export const nl: TranslationKeys = {
|
||||
description:
|
||||
"Achtergrondverlopen, apparaatframes, schaduwen en social media-formaten voor screenshots",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG naar raster",
|
||||
description:
|
||||
@@ -281,6 +317,14 @@ export const nl: TranslationKeys = {
|
||||
name: "Afbeelding naar Base64",
|
||||
description: "Afbeeldingen converteren naar Base64-strings voor gebruik in HTML, CSS en meer",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1515,6 +1559,63 @@ export const nl: TranslationKeys = {
|
||||
submitBatch: "Verfraaien ({count} bestanden)",
|
||||
progressLabel: "Screenshot verfraaien",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Uitvoerformaat",
|
||||
sizing: "Grootte",
|
||||
@@ -1596,6 +1697,23 @@ export const nl: TranslationKeys = {
|
||||
copyImgTag: "<img>-tag kopieren",
|
||||
copyCssUrl: "CSS url() kopieren",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Model",
|
||||
fast: "Snel",
|
||||
|
||||
@@ -264,6 +264,42 @@ export const pl: TranslationKeys = {
|
||||
description:
|
||||
"Dodawanie gradientowych teł, ramek urządzeń, cieni i rozmiarów dla mediów społecznościowych do zrzutów ekranu",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG do rastra",
|
||||
description:
|
||||
@@ -282,6 +318,14 @@ export const pl: TranslationKeys = {
|
||||
name: "Obraz do Base64",
|
||||
description: "Konwersja obrazów na ciągi Base64 do osadzania w HTML, CSS i nie tylko",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1516,6 +1560,63 @@ export const pl: TranslationKeys = {
|
||||
submitBatch: "Upiększ ({count} plików)",
|
||||
progressLabel: "Upiększanie zrzutu ekranu",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Format wyjściowy",
|
||||
sizing: "Rozmiar",
|
||||
@@ -1597,6 +1698,23 @@ export const pl: TranslationKeys = {
|
||||
copyImgTag: "Kopiuj <img>",
|
||||
copyCssUrl: "Kopiuj CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Model",
|
||||
fast: "Szybki",
|
||||
|
||||
@@ -261,6 +261,42 @@ export const ptBR: TranslationKeys = {
|
||||
description:
|
||||
"Adicione fundos degradados, molduras de dispositivo, sombras e tamanhos para redes sociais as capturas de tela",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG para raster",
|
||||
description:
|
||||
@@ -279,6 +315,14 @@ export const ptBR: TranslationKeys = {
|
||||
name: "Imagem para Base64",
|
||||
description: "Converta imagens em strings Base64 para incorporar em HTML, CSS e mais",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1512,6 +1556,63 @@ export const ptBR: TranslationKeys = {
|
||||
submitBatch: "Embelezar ({count} arquivos)",
|
||||
progressLabel: "Embelezando captura de tela",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Formato de saida",
|
||||
sizing: "Dimensionamento",
|
||||
@@ -1593,6 +1694,23 @@ export const ptBR: TranslationKeys = {
|
||||
copyImgTag: "Copiar tag <img>",
|
||||
copyCssUrl: "Copiar CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Modelo",
|
||||
fast: "Rapido",
|
||||
|
||||
@@ -263,6 +263,42 @@ export const ru: TranslationKeys = {
|
||||
description:
|
||||
"Добавление градиентных фонов, рамок устройств, теней и размеров для соцсетей к скриншотам",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG в растр",
|
||||
description:
|
||||
@@ -281,6 +317,14 @@ export const ru: TranslationKeys = {
|
||||
name: "Изображение в Base64",
|
||||
description: "Конвертация изображений в строки Base64 для встраивания в HTML, CSS и другое",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1514,6 +1558,63 @@ export const ru: TranslationKeys = {
|
||||
submitBatch: "Украсить ({count} файлов)",
|
||||
progressLabel: "Украшение скриншота",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Формат вывода",
|
||||
sizing: "Размер",
|
||||
@@ -1595,6 +1696,23 @@ export const ru: TranslationKeys = {
|
||||
copyImgTag: "Копировать <img>",
|
||||
copyCssUrl: "Копировать CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Модель",
|
||||
fast: "Быстрая",
|
||||
|
||||
@@ -261,6 +261,42 @@ export const sv: TranslationKeys = {
|
||||
description:
|
||||
"Lagg till tonade bakgrunder, enhetsramar, skuggor och sociala medier-format for skarmklipp",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG till raster",
|
||||
description:
|
||||
@@ -279,6 +315,14 @@ export const sv: TranslationKeys = {
|
||||
name: "Bild till Base64",
|
||||
description: "Konvertera bilder till Base64-strangar for inbaddning i HTML, CSS och mer",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1511,6 +1555,63 @@ export const sv: TranslationKeys = {
|
||||
submitBatch: "Forskona ({count} filer)",
|
||||
progressLabel: "Forskonar skarmklipp",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Utdataformat",
|
||||
sizing: "Storlek",
|
||||
@@ -1592,6 +1693,23 @@ export const sv: TranslationKeys = {
|
||||
copyImgTag: "Kopiera <img>-tagg",
|
||||
copyCssUrl: "Kopiera CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Modell",
|
||||
fast: "Snabb",
|
||||
|
||||
@@ -257,6 +257,42 @@ export const th: TranslationKeys = {
|
||||
name: "ตกแต่งสกรีนช็อต",
|
||||
description: "เพิ่มพื้นหลังไล่สี กรอบอุปกรณ์ เงา และขนาดสื่อสังคมให้สกรีนช็อต",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG เป็นภาพแรสเตอร์",
|
||||
description:
|
||||
@@ -274,6 +310,14 @@ export const th: TranslationKeys = {
|
||||
name: "ภาพเป็น Base64",
|
||||
description: "แปลงภาพเป็นสตริง Base64 สำหรับฝังใน HTML, CSS และอื่นๆ",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1494,6 +1538,63 @@ export const th: TranslationKeys = {
|
||||
submitBatch: "ตกแต่ง ({count} ไฟล์)",
|
||||
progressLabel: "กำลังตกแต่งสกรีนช็อต",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "รูปแบบเอาต์พุต",
|
||||
sizing: "ขนาด",
|
||||
@@ -1575,6 +1676,23 @@ export const th: TranslationKeys = {
|
||||
copyImgTag: "คัดลอกแท็ก <img>",
|
||||
copyCssUrl: "คัดลอก CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "โมเดล",
|
||||
fast: "เร็ว",
|
||||
|
||||
@@ -263,6 +263,42 @@ export const tr: TranslationKeys = {
|
||||
description:
|
||||
"Ekran görüntülerine gradyan arka planlar, cihaz çerçeveleri, gölgeler ve sosyal medya boyutları ekleyin",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG'den Raster",
|
||||
description:
|
||||
@@ -282,6 +318,14 @@ export const tr: TranslationKeys = {
|
||||
description:
|
||||
"Görüntüleri HTML, CSS ve daha fazlasına gömmek için Base64 dizelerine dönüştürün",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1516,6 +1560,63 @@ export const tr: TranslationKeys = {
|
||||
submitBatch: "Güzelleştir ({count} dosya)",
|
||||
progressLabel: "Ekran görüntüsü güzelleştiriliyor",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Çıktı Biçimi",
|
||||
sizing: "Boyutlandırma",
|
||||
@@ -1597,6 +1698,23 @@ export const tr: TranslationKeys = {
|
||||
copyImgTag: "<img> Etiketi Kopyala",
|
||||
copyCssUrl: "CSS url() Kopyala",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Model",
|
||||
fast: "Hızlı",
|
||||
|
||||
@@ -263,6 +263,42 @@ export const uk: TranslationKeys = {
|
||||
description:
|
||||
"Додавання градієнтних фонів, рамок пристроїв, тіней та розмірів для соцмереж до знімків екрана",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG у растр",
|
||||
description:
|
||||
@@ -281,6 +317,14 @@ export const uk: TranslationKeys = {
|
||||
name: "Зображення у Base64",
|
||||
description: "Конвертація зображень у рядки Base64 для вбудовування у HTML, CSS та інше",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1514,6 +1558,63 @@ export const uk: TranslationKeys = {
|
||||
submitBatch: "Прикрасити ({count} файлів)",
|
||||
progressLabel: "Прикрашання знімку екрана",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Формат виводу",
|
||||
sizing: "Розмір",
|
||||
@@ -1595,6 +1696,23 @@ export const uk: TranslationKeys = {
|
||||
copyImgTag: "Копіювати <img>",
|
||||
copyCssUrl: "Копіювати CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Модель",
|
||||
fast: "Швидка",
|
||||
|
||||
@@ -263,6 +263,42 @@ export const vi: TranslationKeys = {
|
||||
description:
|
||||
"Thêm nền chuyển sắc, khung thiết bị, bóng đổ và kích thước mạng xã hội cho ảnh chụp màn hình",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG sang ảnh raster",
|
||||
description:
|
||||
@@ -282,6 +318,14 @@ export const vi: TranslationKeys = {
|
||||
description:
|
||||
"Chuyển đổi hình ảnh sang chuỗi Base64 để nhúng trong HTML, CSS và nhiều hơn nữa",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1513,6 +1557,63 @@ export const vi: TranslationKeys = {
|
||||
submitBatch: "Làm đẹp ({count} tệp)",
|
||||
progressLabel: "Đang làm đẹp ảnh chụp màn hình",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "Định dạng đầu ra",
|
||||
sizing: "Kích thước",
|
||||
@@ -1594,6 +1695,23 @@ export const vi: TranslationKeys = {
|
||||
copyImgTag: "Sao chép thẻ <img>",
|
||||
copyCssUrl: "Sao chép CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "Mô hình",
|
||||
fast: "Nhanh",
|
||||
|
||||
@@ -215,6 +215,42 @@ export const zhCN: TranslationKeys = {
|
||||
name: "截图美化",
|
||||
description: "为截图添加渐变背景、设备外框、阴影和社交媒体尺寸",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG 转位图",
|
||||
description: "将 SVG 转换为 PNG、JPEG、WebP、AVIF、TIFF、GIF 或 HEIF,支持自定义缩放和 DPI",
|
||||
@@ -231,6 +267,14 @@ export const zhCN: TranslationKeys = {
|
||||
name: "图片转 Base64",
|
||||
description: "将图片转换为 Base64 字符串,可嵌入 HTML、CSS 等",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1448,6 +1492,63 @@ export const zhCN: TranslationKeys = {
|
||||
submitBatch: "美化({count} 个文件)",
|
||||
progressLabel: "正在美化截图",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "输出格式",
|
||||
sizing: "尺寸",
|
||||
@@ -1529,6 +1630,23 @@ export const zhCN: TranslationKeys = {
|
||||
copyImgTag: "复制 <img> 标签",
|
||||
copyCssUrl: "复制 CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "模型",
|
||||
fast: "快速",
|
||||
|
||||
@@ -214,6 +214,42 @@ export const zhTW: TranslationKeys = {
|
||||
name: "美化截圖",
|
||||
description: "為截圖加入漸層背景、裝置外框、陰影和社群媒體尺寸",
|
||||
},
|
||||
"circle-crop": {
|
||||
name: "Circle Crop",
|
||||
description: "Crop image to a centered circle with transparent corners",
|
||||
},
|
||||
duotone: {
|
||||
name: "Duotone",
|
||||
description: "Apply a two-color duotone effect with custom shadow and highlight colors",
|
||||
},
|
||||
"image-pad": {
|
||||
name: "Image Pad",
|
||||
description: "Pad image to a target aspect ratio with a solid color background",
|
||||
},
|
||||
pixelate: {
|
||||
name: "Pixelate",
|
||||
description: "Apply a pixelation effect to the full image or a specific region",
|
||||
},
|
||||
vignette: {
|
||||
name: "Vignette",
|
||||
description: "Add a vignette effect with adjustable strength and color",
|
||||
},
|
||||
"gif-webp": {
|
||||
name: "GIF/WebP Converter",
|
||||
description: "Convert animated GIF to WebP and vice versa, preserving all frames",
|
||||
},
|
||||
histogram: {
|
||||
name: "Histogram",
|
||||
description: "Generate an RGB histogram chart from an image",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
name: "LQIP Placeholder",
|
||||
description: "Generate a tiny low-quality image placeholder with base64 data URI",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
name: "Sprite Sheet",
|
||||
description: "Combine multiple images into a single sprite sheet grid",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
name: "SVG轉點陣圖",
|
||||
description: "將SVG轉換為PNG、JPEG、WebP、AVIF、TIFF、GIF或HEIF,支援自訂縮放和DPI",
|
||||
@@ -230,6 +266,14 @@ export const zhTW: TranslationKeys = {
|
||||
name: "影像轉Base64",
|
||||
description: "將影像轉換為Base64字串,嵌入HTML、CSS等",
|
||||
},
|
||||
"barcode-generate": {
|
||||
name: "Barcode Generator",
|
||||
description: "Generate barcodes in Code 128, EAN-13, UPC-A, Code 39, ITF-14, and Data Matrix",
|
||||
},
|
||||
"chart-maker": {
|
||||
name: "Chart Maker",
|
||||
description: "Create bar, line, or pie charts from CSV or JSON data",
|
||||
},
|
||||
"convert-video": {
|
||||
name: "Convert Video",
|
||||
description: "Convert videos between MP4, MOV, and WebM",
|
||||
@@ -1446,6 +1490,63 @@ export const zhTW: TranslationKeys = {
|
||||
submitBatch: "美化({count}個檔案)",
|
||||
progressLabel: "正在美化截圖",
|
||||
},
|
||||
"circle-crop": {
|
||||
submit: "Circle Crop",
|
||||
submitBatch: "Circle Crop ({count} files)",
|
||||
progressLabel: "Cropping to circle",
|
||||
},
|
||||
duotone: {
|
||||
shadow: "Shadow Color",
|
||||
highlight: "Highlight Color",
|
||||
submit: "Apply Duotone",
|
||||
submitBatch: "Apply Duotone ({count} files)",
|
||||
progressLabel: "Applying duotone",
|
||||
},
|
||||
"image-pad": {
|
||||
target: "Target Ratio",
|
||||
color: "Background Color",
|
||||
submit: "Pad Image",
|
||||
submitBatch: "Pad Image ({count} files)",
|
||||
progressLabel: "Padding image",
|
||||
},
|
||||
pixelate: {
|
||||
blockSize: "Block Size",
|
||||
submit: "Pixelate",
|
||||
submitBatch: "Pixelate ({count} files)",
|
||||
progressLabel: "Pixelating",
|
||||
},
|
||||
vignette: {
|
||||
strength: "Strength",
|
||||
color: "Vignette Color",
|
||||
submit: "Apply Vignette",
|
||||
submitBatch: "Apply Vignette ({count} files)",
|
||||
progressLabel: "Applying vignette",
|
||||
},
|
||||
"gif-webp": {
|
||||
submit: "Convert",
|
||||
submitBatch: "Convert ({count} files)",
|
||||
progressLabel: "Converting animation",
|
||||
},
|
||||
histogram: {
|
||||
submit: "Generate Histogram",
|
||||
submitBatch: "Generate Histogram ({count} files)",
|
||||
progressLabel: "Generating histogram",
|
||||
},
|
||||
"lqip-placeholder": {
|
||||
width: "Width",
|
||||
blur: "Blur",
|
||||
submit: "Generate Placeholder",
|
||||
submitBatch: "Generate Placeholder ({count} files)",
|
||||
progressLabel: "Generating placeholder",
|
||||
},
|
||||
"sprite-sheet": {
|
||||
columns: "Columns",
|
||||
padding: "Padding",
|
||||
background: "Background Color",
|
||||
submit: "Create Sprite Sheet",
|
||||
submitBatch: "Create Sprite Sheet",
|
||||
progressLabel: "Creating sprite sheet",
|
||||
},
|
||||
"svg-to-raster": {
|
||||
outputFormat: "輸出格式",
|
||||
sizing: "尺寸設定",
|
||||
@@ -1527,6 +1628,23 @@ export const zhTW: TranslationKeys = {
|
||||
copyImgTag: "複製<img>標籤",
|
||||
copyCssUrl: "複製CSS url()",
|
||||
},
|
||||
"barcode-generate": {
|
||||
text: "Text",
|
||||
type: "Barcode Type",
|
||||
scale: "Scale",
|
||||
includeText: "Include Text",
|
||||
submit: "Generate Barcode",
|
||||
download: "Download Barcode",
|
||||
},
|
||||
"chart-maker": {
|
||||
kind: "Chart Type",
|
||||
title: "Title",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
submit: "Create Chart",
|
||||
submitBatch: "Create Chart ({count} files)",
|
||||
progressLabel: "Creating chart",
|
||||
},
|
||||
"enhance-faces-standalone": {
|
||||
model: "模型",
|
||||
fast: "快速",
|
||||
|
||||
Generated
+9
@@ -170,6 +170,9 @@ importers:
|
||||
bullmq:
|
||||
specifier: ^5.78.0
|
||||
version: 5.78.0
|
||||
bwip-js:
|
||||
specifier: ^4.11.1
|
||||
version: 4.11.1
|
||||
dotenv:
|
||||
specifier: ^16.4.0
|
||||
version: 16.6.1
|
||||
@@ -3922,6 +3925,10 @@ packages:
|
||||
redis:
|
||||
optional: true
|
||||
|
||||
bwip-js@4.11.1:
|
||||
resolution: {integrity: sha512-9KCjsJF/VSETOS6jPgNxd6X9yCHTbqcm2zccDceX7bOmxDDjQ92V9S+iFf+UD5+hijUZDw7SFsYT7vL2rW7qcQ==}
|
||||
hasBin: true
|
||||
|
||||
byline@5.0.0:
|
||||
resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -11091,6 +11098,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bwip-js@4.11.1: {}
|
||||
|
||||
byline@5.0.0: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Integration tests for the barcode-generate tool (/api/v1/tools/barcode-generate).
|
||||
*
|
||||
* Custom JSON POST route mirroring qr-generate. Generates barcode images
|
||||
* from text input using bwip-js. Tests cover code128, ean13 validation,
|
||||
* download verification, and invalid-text rejection.
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Barcode Generate", () => {
|
||||
it("generates a code128 barcode from text", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "SnapOtter",
|
||||
type: "code128",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.jobId).toBeDefined();
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
|
||||
// Download and verify PNG magic bytes
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
});
|
||||
expect(dlRes.statusCode).toBe(200);
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
});
|
||||
|
||||
it("generates a valid ean13 barcode from 13-digit number", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "4006381333931",
|
||||
type: "ean13",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects ean13 with non-numeric text", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "abc",
|
||||
type: "ean13",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toMatch(/invalid text/i);
|
||||
});
|
||||
|
||||
it("uses code128 as default type", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "DefaultType",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("generates a UPC-A barcode", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "012345678905",
|
||||
type: "upca",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("generates a code39 barcode", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "SNAP-123",
|
||||
type: "code39",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("generates an itf14 barcode", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "10012345678902",
|
||||
type: "itf14",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("generates a datamatrix barcode", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "Hello DataMatrix",
|
||||
type: "datamatrix",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects request without text", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toMatch(/invalid settings/i);
|
||||
});
|
||||
|
||||
it("rejects empty text", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { text: "" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid barcode type", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { text: "test", type: "qrcode-micro" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { text: "test" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("returns all expected fields in the response", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { text: "structure test" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result).toHaveProperty("jobId");
|
||||
expect(result).toHaveProperty("downloadUrl");
|
||||
expect(result).toHaveProperty("originalSize");
|
||||
expect(result).toHaveProperty("processedSize");
|
||||
expect(result.originalSize).toBe(0);
|
||||
});
|
||||
|
||||
it("respects scale parameter", async () => {
|
||||
const resSmall = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { text: "scale test", scale: 1 },
|
||||
});
|
||||
|
||||
const resLarge = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { text: "scale test", scale: 8 },
|
||||
});
|
||||
|
||||
expect(resSmall.statusCode).toBe(200);
|
||||
expect(resLarge.statusCode).toBe(200);
|
||||
|
||||
const sizeSmall = JSON.parse(resSmall.body).processedSize;
|
||||
const sizeLarge = JSON.parse(resLarge.body).processedSize;
|
||||
expect(sizeLarge).toBeGreaterThan(sizeSmall);
|
||||
});
|
||||
|
||||
it("supports includeText toggle", async () => {
|
||||
const resWithText = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { text: "toggle test", includeText: true },
|
||||
});
|
||||
|
||||
const resNoText = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/barcode-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { text: "toggle test", includeText: false },
|
||||
});
|
||||
|
||||
expect(resWithText.statusCode).toBe(200);
|
||||
expect(resNoText.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Integration tests for the chart-maker tool (/api/v1/tools/chart-maker).
|
||||
*
|
||||
* Factory FILE tool that consumes CSV/JSON and renders hand-rolled SVG
|
||||
* rasterized to PNG via sharp. Tests cover bar/line/pie kinds, CSV and JSON
|
||||
* inputs, numeric validation, SVG label escaping, and invalid kind rejection.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
function makeCsv(rows: [string, number][]): Buffer {
|
||||
const lines = ["label,value", ...rows.map(([l, v]) => `${l},${v}`)];
|
||||
return Buffer.from(lines.join("\n"), "utf8");
|
||||
}
|
||||
|
||||
describe("Chart Maker", () => {
|
||||
it("generates a bar chart from a 4-row CSV", async () => {
|
||||
const csv = makeCsv([
|
||||
["Apples", 10],
|
||||
["Bananas", 25],
|
||||
["Cherries", 15],
|
||||
["Dates", 30],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
|
||||
// Download and verify PNG
|
||||
const dlRes = await app.inject({ method: "GET", url: result.downloadUrl });
|
||||
expect(dlRes.statusCode).toBe(200);
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
expect(meta.width).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("generates a chart from JSON object input", async () => {
|
||||
const jsonData = JSON.stringify({ Apples: 10, Bananas: 25, Cherries: 15 });
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "data.json",
|
||||
contentType: "application/json",
|
||||
content: Buffer.from(jsonData),
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("generates a pie chart", async () => {
|
||||
const csv = makeCsv([
|
||||
["Red", 40],
|
||||
["Blue", 30],
|
||||
["Green", 30],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "pie" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("generates a line chart", async () => {
|
||||
const csv = makeCsv([
|
||||
["Jan", 10],
|
||||
["Feb", 20],
|
||||
["Mar", 15],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "line" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects CSV with non-numeric column 2", async () => {
|
||||
const csv = Buffer.from("label,value\nApples,lots\nBananas,many\n", "utf8");
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "bad.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error + " " + (result.details ?? "")).toMatch(/numeric/i);
|
||||
});
|
||||
|
||||
it("escapes SVG-injection labels and produces valid PNG", async () => {
|
||||
const csv = makeCsv([["<script>alert(1)</script>", 10]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "xss.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
// Download and verify it decodes as PNG (the escape proof: no SVG parse error)
|
||||
const dlRes = await app.inject({ method: "GET", url: result.downloadUrl });
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
});
|
||||
|
||||
it("rejects invalid chart kind", async () => {
|
||||
const csv = makeCsv([["A", 1]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ kind: "donut" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("uses default kind bar when no kind specified", async () => {
|
||||
const csv = makeCsv([
|
||||
["A", 10],
|
||||
["B", 20],
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("generates a chart from JSON array input", async () => {
|
||||
const jsonData = JSON.stringify([
|
||||
{ label: "A", value: 10 },
|
||||
{ label: "B", value: 20 },
|
||||
{ label: "C", value: 30 },
|
||||
]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "data.json",
|
||||
contentType: "application/json",
|
||||
content: Buffer.from(jsonData),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("respects custom title", async () => {
|
||||
const csv = makeCsv([["A", 10]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
{ name: "settings", content: JSON.stringify({ title: "Sales Report" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
const csv = makeCsv([["A", 10]]);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/chart-maker",
|
||||
headers: { "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Integration tests for the circle-crop tool (/api/v1/tools/circle-crop).
|
||||
*
|
||||
* Covers circular masking, PNG output, corner alpha transparency,
|
||||
* and center pixel opacity.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Circle Crop", () => {
|
||||
it("produces a circular crop as PNG", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/circle-crop",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
// Output should be square: min(200, 150) = 150
|
||||
expect(meta.width).toBe(150);
|
||||
expect(meta.height).toBe(150);
|
||||
});
|
||||
|
||||
it("corner pixel has alpha 0 (transparent)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/circle-crop",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Read raw RGBA pixels and check corner (1,1) alpha
|
||||
const rawMeta = await sharp(dlRes.rawPayload).raw().toBuffer({ resolveWithObject: true });
|
||||
const { data, info } = rawMeta;
|
||||
// Pixel at (1, 1): offset = (1 * info.width + 1) * info.channels
|
||||
const offset = (1 * info.width + 1) * info.channels;
|
||||
const alpha = data[offset + 3]; // 4th channel = alpha
|
||||
expect(alpha).toBe(0);
|
||||
});
|
||||
|
||||
it("center pixel has alpha 255 (opaque)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/circle-crop",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const rawMeta = await sharp(dlRes.rawPayload).raw().toBuffer({ resolveWithObject: true });
|
||||
const { data, info } = rawMeta;
|
||||
// Center pixel
|
||||
const cx = Math.floor(info.width / 2);
|
||||
const cy = Math.floor(info.height / 2);
|
||||
const offset = (cy * info.width + cx) * info.channels;
|
||||
const alpha = data[offset + 3];
|
||||
expect(alpha).toBe(255);
|
||||
});
|
||||
|
||||
it("always outputs PNG regardless of input format", async () => {
|
||||
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.jpg", contentType: "image/jpeg", content: JPG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/circle-crop",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
expect(meta.channels).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@
|
||||
* Also tests SVG-to-raster via the dedicated endpoint.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -30,11 +32,26 @@ const OUTPUT_FORMATS = [
|
||||
"jp2",
|
||||
"qoi",
|
||||
"psd",
|
||||
"ppm",
|
||||
"eps",
|
||||
"tga",
|
||||
] as const;
|
||||
|
||||
// Formats whose CLI encoder (cjxl, heif-enc, opj_compress, magick) may not
|
||||
// be installed in every dev/CI environment. Allow graceful 422 for these.
|
||||
const CLI_ENCODED_FORMATS = new Set(["heic", "heif", "jxl", "bmp", "ico", "jp2", "qoi", "psd"]);
|
||||
const CLI_ENCODED_FORMATS = new Set([
|
||||
"heic",
|
||||
"heif",
|
||||
"jxl",
|
||||
"bmp",
|
||||
"ico",
|
||||
"jp2",
|
||||
"qoi",
|
||||
"psd",
|
||||
"ppm",
|
||||
"eps",
|
||||
"tga",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
@@ -295,7 +312,7 @@ describe("SVG via dedicated svg-to-raster endpoint", () => {
|
||||
expect(highBody.processedSize).toBeGreaterThan(lowBody.processedSize);
|
||||
});
|
||||
|
||||
// Non-browser formats should include a previewUrl
|
||||
// Non-browser formats should include a previewUrl (moved below animated tests)
|
||||
it("returns previewUrl for non-browser format (tiff)", async () => {
|
||||
const input = inputs.svg;
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
@@ -327,3 +344,86 @@ describe("SVG via dedicated svg-to-raster endpoint", () => {
|
||||
expect(body.previewUrl).toContain("preview.webp");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animated format preservation via /api/v1/tools/convert
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Animated format preservation", () => {
|
||||
it("preserves animation when converting animated GIF to WebP", async () => {
|
||||
const animatedGif = readFileSync(join(FIXTURES, "animated.gif"));
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "animated.gif",
|
||||
contentType: "image/gif",
|
||||
content: animatedGif,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toContain(".webp");
|
||||
|
||||
// Download the output and verify it has multiple frames
|
||||
const dl = await app.inject({
|
||||
method: "GET",
|
||||
url: body.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const tmpPath = join(tmpdir(), `animated-check-${randomUUID()}.webp`);
|
||||
writeFileSync(tmpPath, dl.rawPayload);
|
||||
const meta = await sharp(tmpPath, { animated: true }).metadata();
|
||||
expect(meta.pages).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("converts animated GIF to PNG as single frame without error", async () => {
|
||||
const animatedGif = readFileSync(join(FIXTURES, "animated.gif"));
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "animated.gif",
|
||||
contentType: "image/gif",
|
||||
content: animatedGif,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify({ format: "png" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toContain(".png");
|
||||
|
||||
// Download and verify single frame
|
||||
const dl = await app.inject({
|
||||
method: "GET",
|
||||
url: body.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
|
||||
const meta = await sharp(dl.rawPayload).metadata();
|
||||
expect(meta.pages ?? 1).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Integration tests for the duotone tool (/api/v1/tools/duotone).
|
||||
*
|
||||
* Covers duotone color mapping, black-to-shadow and white-to-highlight
|
||||
* pixel assertions with tolerance, and schema validation.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Duotone", () => {
|
||||
it("maps black pixels to shadow color and white pixels to highlight color", async () => {
|
||||
// Create a 2x1 image: left pixel black, right pixel white
|
||||
const testImage = await sharp({
|
||||
create: { width: 2, height: 1, channels: 3, background: { r: 0, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Composite a white pixel on the right
|
||||
const whitePixel = await sharp({
|
||||
create: { width: 1, height: 1, channels: 3, background: { r: 255, g: 255, b: 255 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const bwImage = await sharp(testImage)
|
||||
.composite([{ input: whitePixel, left: 1, top: 0 }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const shadow = "#1e3a8a";
|
||||
const highlight = "#fbbf24";
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "bw.png", contentType: "image/png", content: bwImage },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ shadow, highlight }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/duotone",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const rawMeta = await sharp(dlRes.rawPayload)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const { data, info } = rawMeta;
|
||||
|
||||
const tolerance = 5;
|
||||
|
||||
// Black pixel (0,0) should map to shadow (#1e3a8a = R:30, G:58, B:138)
|
||||
expect(Math.abs(data[0] - 30)).toBeLessThanOrEqual(tolerance);
|
||||
expect(Math.abs(data[1] - 58)).toBeLessThanOrEqual(tolerance);
|
||||
expect(Math.abs(data[2] - 138)).toBeLessThanOrEqual(tolerance);
|
||||
|
||||
// White pixel (1,0) should map to highlight (#fbbf24 = R:251, G:191, B:36)
|
||||
const rightOffset = 1 * info.channels;
|
||||
expect(Math.abs(data[rightOffset] - 251)).toBeLessThanOrEqual(tolerance);
|
||||
expect(Math.abs(data[rightOffset + 1] - 191)).toBeLessThanOrEqual(tolerance);
|
||||
expect(Math.abs(data[rightOffset + 2] - 36)).toBeLessThanOrEqual(tolerance);
|
||||
});
|
||||
|
||||
it("applies default duotone settings", async () => {
|
||||
const testImage = await sharp({
|
||||
create: { width: 50, height: 50, channels: 3, background: { r: 128, g: 128, b: 128 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "gray.png", contentType: "image/png", content: testImage },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/duotone",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects invalid hex color for shadow", async () => {
|
||||
const testImage = await sharp({
|
||||
create: { width: 10, height: 10, channels: 3, background: { r: 0, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: testImage },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ shadow: "badcolor", highlight: "#fbbf24" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/duotone",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* response via reply.hijack().
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
@@ -13,6 +14,23 @@ import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
/** Check if ImageMagick is available on this system. */
|
||||
function checkMagickAvailable(): boolean {
|
||||
try {
|
||||
execFileSync("magick", ["--version"], { timeout: 5_000, stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
execFileSync("convert", ["--version"], { timeout: 5_000, stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HAS_MAGICK = checkMagickAvailable();
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
@@ -573,12 +591,8 @@ describe("favicon", () => {
|
||||
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
||||
const icoEntry = zip.getEntry("favicon.ico");
|
||||
expect(icoEntry).toBeDefined();
|
||||
const icoData = icoEntry?.getData();
|
||||
const icoData = icoEntry!.getData();
|
||||
expect(icoData.length).toBeGreaterThan(0);
|
||||
// ICO is generated as PNG which starts with PNG magic bytes
|
||||
const meta = await sharp(icoData).metadata();
|
||||
expect(meta.width).toBe(32);
|
||||
expect(meta.height).toBe(32);
|
||||
});
|
||||
|
||||
// ── Three images produce three subfolders ────────────────────────
|
||||
@@ -674,7 +688,7 @@ describe("favicon", () => {
|
||||
|
||||
// ── Verify ICO dimensions ───────────────────────────────────────
|
||||
|
||||
it("favicon.ico has correct 32x32 dimensions", async () => {
|
||||
it("favicon.ico has correct dimensions", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
||||
]);
|
||||
@@ -690,10 +704,20 @@ describe("favicon", () => {
|
||||
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
||||
const icoEntry = zip.getEntry("favicon.ico");
|
||||
expect(icoEntry).toBeDefined();
|
||||
const meta = await sharp(icoEntry?.getData()).metadata();
|
||||
expect(meta.width).toBe(32);
|
||||
expect(meta.height).toBe(32);
|
||||
expect(meta.format).toBe("png");
|
||||
const icoData = icoEntry!.getData();
|
||||
expect(icoData.length).toBeGreaterThan(0);
|
||||
|
||||
if (HAS_MAGICK) {
|
||||
// Real ICO header
|
||||
expect(icoData[0]).toBe(0x00);
|
||||
expect(icoData[2]).toBe(0x01);
|
||||
} else {
|
||||
// Fallback: PNG wrapped as .ico
|
||||
const meta = await sharp(icoData).metadata();
|
||||
expect(meta.width).toBe(32);
|
||||
expect(meta.height).toBe(32);
|
||||
expect(meta.format).toBe("png");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Apple touch icon is 180x180 ─────────────────────────────────
|
||||
@@ -830,9 +854,46 @@ describe("favicon", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Multi-size ICO (magick-gated) ──────────────────────────────
|
||||
|
||||
it("favicon.ico is a real multi-size ICO when magick is available", async () => {
|
||||
if (!HAS_MAGICK) {
|
||||
// Skip the ICO header assertion when magick is unavailable;
|
||||
// the fallback produces a PNG-renamed-as-ICO which is still functional.
|
||||
return;
|
||||
}
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/favicon",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const zip = new AdmZip(Buffer.from(res.rawPayload));
|
||||
const icoEntry = zip.getEntry("favicon.ico");
|
||||
expect(icoEntry).toBeDefined();
|
||||
const icoData = icoEntry!.getData();
|
||||
|
||||
// Real ICO header: bytes 0-1 = 0x0000 (reserved), bytes 2-3 = 0x0100 (type 1 = ICO)
|
||||
expect(icoData[0]).toBe(0x00);
|
||||
expect(icoData[1]).toBe(0x00);
|
||||
expect(icoData[2]).toBe(0x01);
|
||||
expect(icoData[3]).toBe(0x00);
|
||||
|
||||
// Image count at offset 4-5 (little-endian); should be >= 4 (16/32/48/256)
|
||||
const imageCount = icoData[4] | (icoData[5] << 8);
|
||||
expect(imageCount).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
// ── ICO output has valid content ────────────────────────────────
|
||||
|
||||
it("ICO output can be read by Sharp and is 32x32", async () => {
|
||||
it("ICO output has valid content", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: PNG },
|
||||
]);
|
||||
@@ -849,13 +910,19 @@ describe("favicon", () => {
|
||||
const icoEntry = zip.getEntry("favicon.ico");
|
||||
expect(icoEntry).toBeDefined();
|
||||
|
||||
const icoData = icoEntry?.getData();
|
||||
const icoData = icoEntry!.getData();
|
||||
expect(icoData.length).toBeGreaterThan(0);
|
||||
|
||||
// The ICO is actually a PNG wrapped, so Sharp can read it
|
||||
const meta = await sharp(icoData).metadata();
|
||||
expect(meta.width).toBe(32);
|
||||
expect(meta.height).toBe(32);
|
||||
if (HAS_MAGICK) {
|
||||
// Real ICO: header check
|
||||
expect(icoData[0]).toBe(0x00);
|
||||
expect(icoData[2]).toBe(0x01);
|
||||
} else {
|
||||
// Fallback: PNG wrapped, so Sharp can read it
|
||||
const meta = await sharp(icoData).metadata();
|
||||
expect(meta.width).toBe(32);
|
||||
expect(meta.height).toBe(32);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Favicon from stress-large.jpg verifies 512x512 is square ───
|
||||
|
||||
@@ -131,9 +131,14 @@ describe("tool x format matrix (generated)", () => {
|
||||
!NON_RASTER_OUTPUT.has(outType.split(";")[0]) && outType.startsWith("image/");
|
||||
const sharpDecodable =
|
||||
isRaster &&
|
||||
!["image/heic", "image/heif", "image/x-icon", "image/qoi"].includes(
|
||||
outType.split(";")[0],
|
||||
);
|
||||
![
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/x-icon",
|
||||
"image/qoi",
|
||||
"image/x-portable-pixmap",
|
||||
"image/x-tga",
|
||||
].includes(outType.split(";")[0]);
|
||||
if (sharpDecodable) {
|
||||
// The processed output must actually decode; a corrupt "success" is a bug.
|
||||
const meta = await sharp(dl.rawPayload).metadata();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Integration tests for the gif-webp tool (/api/v1/tools/gif-webp).
|
||||
*
|
||||
* Covers GIF-to-WebP, WebP-to-GIF conversions with animation preservation,
|
||||
* and extension validation.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const ANIMATED_GIF = readFileSync(join(FIXTURES, "animated.gif"));
|
||||
const ANIMATED_WEBP = readFileSync(join(FIXTURES, "animated.webp"));
|
||||
const STILL_PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("GIF/WebP Converter", () => {
|
||||
it("converts animated GIF to WebP with multiple pages", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "anim.gif", contentType: "image/gif", content: ANIMATED_GIF },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/gif-webp",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
|
||||
// Download and verify animated WebP (pages > 1)
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload, { animated: true }).metadata();
|
||||
expect(meta.pages).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("converts animated WebP to GIF with multiple pages", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "anim.webp", contentType: "image/webp", content: ANIMATED_WEBP },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/gif-webp",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
|
||||
// Download and verify animated GIF (pages > 1)
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload, { animated: true }).metadata();
|
||||
expect(meta.pages).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("rejects PNG input with extension guard", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: STILL_PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/gif-webp",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(res.statusCode).toBeLessThan(500);
|
||||
const result = JSON.parse(res.body);
|
||||
const msg = [result.error, result.details].join(" ");
|
||||
expect(msg).toMatch(/only gif and webp/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Integration tests for the histogram tool (/api/v1/tools/histogram).
|
||||
*
|
||||
* Covers PNG output, mean fields, and channel accuracy with a pure-red fixture.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Histogram", () => {
|
||||
it("generates a PNG histogram with mean fields", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/histogram",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.mean).toBeDefined();
|
||||
expect(result.mean.r).toBeGreaterThanOrEqual(0);
|
||||
expect(result.mean.g).toBeGreaterThanOrEqual(0);
|
||||
expect(result.mean.b).toBeGreaterThanOrEqual(0);
|
||||
expect(result.max).toBeDefined();
|
||||
|
||||
// Download and verify PNG magic bytes
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
// PNG magic: 89 50 4E 47
|
||||
expect(dlRes.rawPayload[0]).toBe(0x89);
|
||||
expect(dlRes.rawPayload[1]).toBe(0x50);
|
||||
expect(dlRes.rawPayload[2]).toBe(0x4e);
|
||||
expect(dlRes.rawPayload[3]).toBe(0x47);
|
||||
});
|
||||
|
||||
it("pure-red fixture has mean.r > mean.g", async () => {
|
||||
// Generate a pure red 10x10 PNG in-memory
|
||||
const redPng = await sharp({
|
||||
create: { width: 10, height: 10, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "red.png", contentType: "image/png", content: redPng },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/histogram",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.mean.r).toBeGreaterThan(result.mean.g);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Integration tests for the image-pad tool (/api/v1/tools/image-pad).
|
||||
*
|
||||
* Covers aspect-ratio padding, color application, dimension math,
|
||||
* and schema validation.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { canvasFor } from "../../apps/api/src/routes/tools/image-pad.js";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Image Pad", () => {
|
||||
// Unit tests for canvasFor helper
|
||||
it("canvasFor: 200x150 with 1:1 produces a 200x200 canvas", () => {
|
||||
const { cw, ch } = canvasFor(200, 150, "1:1");
|
||||
expect(cw).toBe(200);
|
||||
expect(ch).toBe(200);
|
||||
});
|
||||
|
||||
it("canvasFor: 200x150 with 16:9 produces correct canvas", () => {
|
||||
const { cw, ch } = canvasFor(200, 150, "16:9");
|
||||
// 200/150 = 1.33, 16/9 = 1.78; image is narrower, expand width
|
||||
expect(cw).toBe(Math.round(150 * (16 / 9)));
|
||||
expect(ch).toBe(150);
|
||||
});
|
||||
|
||||
it("pads a non-square image to 1:1", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ target: "1:1", color: "#ffffff" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image-pad",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
// 200x150 padded to 1:1 => 200x200
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(200);
|
||||
});
|
||||
|
||||
it("applies the specified padding color at corners", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ target: "1:1", color: "#ff0000" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image-pad",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Check corner pixel color (top-left should be padding color)
|
||||
const rawMeta = await sharp(dlRes.rawPayload).raw().toBuffer({ resolveWithObject: true });
|
||||
const { data } = rawMeta;
|
||||
// Pixel (0, 0) should be #ff0000
|
||||
expect(data[0]).toBe(255); // R
|
||||
expect(data[1]).toBe(0); // G
|
||||
expect(data[2]).toBe(0); // B
|
||||
});
|
||||
|
||||
it("rejects invalid color format", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ target: "1:1", color: "red" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image-pad",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("uses default settings when none provided", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/image-pad",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
// Default 1:1 on 200x150 -> 200x200
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Integration tests for the lqip-placeholder tool (/api/v1/tools/lqip-placeholder).
|
||||
*
|
||||
* Covers LQIP generation, data URI prefix, output size, and schema validation.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("LQIP Placeholder", () => {
|
||||
it("generates a placeholder with data URI", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/lqip-placeholder",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.dataUri).toMatch(/^data:image\/webp;base64,/);
|
||||
expect(result.width).toBeGreaterThan(0);
|
||||
expect(result.height).toBeGreaterThan(0);
|
||||
expect(result.bytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("produces output under 5 KiB", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ width: 16, blur: 2 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/lqip-placeholder",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
// Download the output and verify size
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(dlRes.rawPayload.length).toBeLessThan(5 * 1024);
|
||||
});
|
||||
|
||||
it("rejects width above maximum (200)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ width: 200 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/lqip-placeholder",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Integration tests for the pixelate tool (/api/v1/tools/pixelate).
|
||||
*
|
||||
* Covers full-image pixelation, region pixelation, dimension preservation,
|
||||
* region bounds validation, and schema validation.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Pixelate", () => {
|
||||
it("pixelates entire image with default blockSize", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pixelate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves dimensions after pixelation", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ blockSize: 20 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pixelate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(150);
|
||||
});
|
||||
|
||||
it("pixelates a specific region", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
blockSize: 10,
|
||||
region: { left: 10, top: 10, width: 50, height: 50 },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pixelate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(150);
|
||||
});
|
||||
|
||||
it("rejects region exceeding image bounds", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
blockSize: 10,
|
||||
region: { left: 180, top: 130, width: 50, height: 50 },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pixelate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects blockSize below minimum (1)", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ blockSize: 1 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/pixelate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -1224,6 +1224,97 @@ describe("QR Generate", () => {
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── Logo embed ────────────────────────────────────────────────
|
||||
|
||||
it("logo embed produces different output from no-logo render", async () => {
|
||||
// Generate a small red PNG as a logo data URI
|
||||
const logoPng = await sharp({
|
||||
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const logoDataUri = `data:image/png;base64,${logoPng.toString("base64")}`;
|
||||
|
||||
const resNoLogo = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/qr-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "logo test",
|
||||
size: 400,
|
||||
},
|
||||
});
|
||||
expect(resNoLogo.statusCode).toBe(200);
|
||||
|
||||
const resWithLogo = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/qr-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "logo test",
|
||||
size: 400,
|
||||
logoDataUri,
|
||||
},
|
||||
});
|
||||
expect(resWithLogo.statusCode).toBe(200);
|
||||
|
||||
const noLogoResult = JSON.parse(resNoLogo.body);
|
||||
const withLogoResult = JSON.parse(resWithLogo.body);
|
||||
|
||||
// Download both and verify output bytes differ
|
||||
const dlNoLogo = await app.inject({ method: "GET", url: noLogoResult.downloadUrl });
|
||||
const dlWithLogo = await app.inject({ method: "GET", url: withLogoResult.downloadUrl });
|
||||
|
||||
expect(dlNoLogo.rawPayload.equals(dlWithLogo.rawPayload)).toBe(false);
|
||||
|
||||
// Verify the logo version is still valid PNG
|
||||
const meta = await sharp(dlWithLogo.rawPayload).metadata();
|
||||
expect(meta.format).toBe("png");
|
||||
expect(meta.width).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid logo base64 data", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/qr-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "bad logo test",
|
||||
logoDataUri: "data:image/png;base64,dGhpcyBpcyBub3QgYSByZWFsIGltYWdl",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toMatch(/invalid logo/i);
|
||||
});
|
||||
|
||||
it("rejects malformed logo data URI format", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/qr-generate",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: {
|
||||
text: "bad uri format",
|
||||
logoDataUri: "not-a-data-uri",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
// ── Output is always square ──────────────────────────────────
|
||||
|
||||
it("output image is always square regardless of content", async () => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Integration tests for the sprite-sheet tool (/api/v1/tools/sprite-sheet).
|
||||
*
|
||||
* Covers grid layout, dimension math, frames payload, and minimum input guard.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Sprite Sheet", () => {
|
||||
it("creates a sprite sheet from 4 images with 2 columns", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
||||
{ name: "file", filename: "b.png", contentType: "image/png", content: PNG },
|
||||
{ name: "file", filename: "c.jpg", contentType: "image/jpeg", content: JPG },
|
||||
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", content: JPG },
|
||||
{ name: "settings", content: JSON.stringify({ columns: 2 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/sprite-sheet",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.frames).toHaveLength(4);
|
||||
|
||||
// Download and verify dimensions: 2 cols x 2 rows of 200x150 cells
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
// First image is 200x150, so cells are 200x150
|
||||
// 2 cols, 2 rows, no padding
|
||||
expect(meta.width).toBe(200 * 2);
|
||||
expect(meta.height).toBe(150 * 2);
|
||||
});
|
||||
|
||||
it("rejects single input", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/sprite-sheet",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// The factory wraps InputValidationError as 422
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(res.statusCode).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
* URLs (consolidated into adjust-colors).
|
||||
*/
|
||||
const REGISTRY_EXEMPT = new Set([
|
||||
"barcode-generate",
|
||||
"barcode-read",
|
||||
"bulk-rename",
|
||||
"collage",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Integration tests for the vignette tool (/api/v1/tools/vignette).
|
||||
*
|
||||
* Covers vignette application, corner-darker-than-source assertion,
|
||||
* dimension preservation, and schema validation.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
describe("Vignette", () => {
|
||||
it("applies vignette effect with default settings", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/vignette",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("corner pixel is darker than the original source corner", async () => {
|
||||
// Create a known white image so we can measure darkening
|
||||
const whiteBuf = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 255, b: 255 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "white.png", contentType: "image/png", content: whiteBuf },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ strength: 0.8, color: "#000000" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/vignette",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const rawMeta = await sharp(dlRes.rawPayload)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const { data } = rawMeta;
|
||||
// Corner pixel (0,0) should be darker than 255 (the original white)
|
||||
const cornerR = data[0];
|
||||
expect(cornerR).toBeLessThan(255);
|
||||
});
|
||||
|
||||
it("preserves dimensions", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ strength: 0.5 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/vignette",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(150);
|
||||
});
|
||||
|
||||
it("rejects strength above maximum", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({ strength: 2 }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/vignette",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,41 @@ vi.mock("@/components/tools/split-csv-settings", () => ({
|
||||
SplitCsvSettings: () => null,
|
||||
}));
|
||||
|
||||
// Wave 5a: Image gap-fill tools
|
||||
vi.mock("@/components/tools/barcode-generate-settings", () => ({
|
||||
BarcodeGenerateSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/chart-maker-settings", () => ({
|
||||
ChartMakerSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/circle-crop-settings", () => ({
|
||||
CircleCropSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/duotone-settings", () => ({
|
||||
DuotoneSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/gif-webp-settings", () => ({
|
||||
GifWebpSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/histogram-settings", () => ({
|
||||
HistogramSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/image-pad-settings", () => ({
|
||||
ImagePadSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/lqip-placeholder-settings", () => ({
|
||||
LqipPlaceholderSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/pixelate-settings", () => ({
|
||||
PixelateSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/sprite-sheet-settings", () => ({
|
||||
SpriteSheetSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/vignette-settings", () => ({
|
||||
VignetteSettings: () => null,
|
||||
}));
|
||||
|
||||
import { TOOLS } from "@snapotter/shared";
|
||||
import type { DisplayMode, ToolRegistryEntry } from "@/lib/tool-registry";
|
||||
import { getToolRegistryEntry, toolRegistry } from "@/lib/tool-registry";
|
||||
|
||||
@@ -212,6 +212,41 @@ vi.mock("@/components/tools/split-csv-settings", () => ({
|
||||
SplitCsvSettings: () => null,
|
||||
}));
|
||||
|
||||
// Wave 5a: Image gap-fill tools
|
||||
vi.mock("@/components/tools/barcode-generate-settings", () => ({
|
||||
BarcodeGenerateSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/chart-maker-settings", () => ({
|
||||
ChartMakerSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/circle-crop-settings", () => ({
|
||||
CircleCropSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/duotone-settings", () => ({
|
||||
DuotoneSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/gif-webp-settings", () => ({
|
||||
GifWebpSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/histogram-settings", () => ({
|
||||
HistogramSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/image-pad-settings", () => ({
|
||||
ImagePadSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/lqip-placeholder-settings", () => ({
|
||||
LqipPlaceholderSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/pixelate-settings", () => ({
|
||||
PixelateSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/sprite-sheet-settings", () => ({
|
||||
SpriteSheetSettings: () => null,
|
||||
}));
|
||||
vi.mock("@/components/tools/vignette-settings", () => ({
|
||||
VignetteSettings: () => null,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -340,7 +375,7 @@ describe("toolRegistry", () => {
|
||||
});
|
||||
|
||||
it("tools with no-dropzone display mode have a ResultsPanel", () => {
|
||||
const selfContainedTools = new Set(["meme-generator"]);
|
||||
const selfContainedTools = new Set(["meme-generator", "barcode-generate"]);
|
||||
for (const [toolId, entry] of toolRegistry) {
|
||||
if (entry.displayMode === "no-dropzone" && !selfContainedTools.has(toolId)) {
|
||||
expect(entry.ResultsPanel, `missing ResultsPanel for ${toolId}`).toBeDefined();
|
||||
|
||||
Reference in New Issue
Block a user