From 5f98b485935f07e40c0a6e57548892b2e17fc03b Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 10:19:00 +0800 Subject: [PATCH] feat(tools): 2.0 phase 5 wave 3a - video depth (22 tools) (#221) --- .../meta/0000_snapshot.json | 704 +++------------ .../meta/0001_snapshot.json | 851 ++++++++++++++---- apps/api/src/lib/media-tool.ts | 51 +- apps/api/src/modality/media-input.ts | 65 +- apps/api/src/routes/tool-factory.ts | 27 +- apps/api/src/routes/tools/aspect-pad.ts | 97 ++ apps/api/src/routes/tools/blur-pad.ts | 98 ++ apps/api/src/routes/tools/burn-subtitles.ts | 73 ++ apps/api/src/routes/tools/change-fps.ts | 46 + apps/api/src/routes/tools/crop-video.ts | 68 ++ apps/api/src/routes/tools/embed-subtitles.ts | 76 ++ .../api/src/routes/tools/extract-subtitles.ts | 42 + apps/api/src/routes/tools/gif-to-video.ts | 63 ++ apps/api/src/routes/tools/images-to-video.ts | 79 ++ apps/api/src/routes/tools/index.ts | 48 +- apps/api/src/routes/tools/merge-videos.ts | 90 ++ apps/api/src/routes/tools/replace-audio.ts | 73 ++ apps/api/src/routes/tools/resize-video.ts | 71 ++ apps/api/src/routes/tools/reverse-video.ts | 80 ++ apps/api/src/routes/tools/rotate-video.ts | 54 ++ apps/api/src/routes/tools/stabilize-video.ts | 69 ++ apps/api/src/routes/tools/video-color.ts | 49 + apps/api/src/routes/tools/video-loudnorm.ts | 52 ++ apps/api/src/routes/tools/video-metadata.ts | 48 + apps/api/src/routes/tools/video-speed.ts | 101 +++ apps/api/src/routes/tools/video-to-frames.ts | 126 +++ apps/api/src/routes/tools/video-to-webp.ts | 44 + apps/api/src/routes/tools/watermark-video.ts | 80 ++ .../components/tools/aspect-pad-settings.tsx | 92 ++ .../components/tools/blur-pad-settings.tsx | 92 ++ .../tools/burn-subtitles-settings.tsx | 65 ++ .../components/tools/change-fps-settings.tsx | 71 ++ .../components/tools/crop-video-settings.tsx | 115 +++ .../tools/embed-subtitles-settings.tsx | 62 ++ .../tools/extract-subtitles-settings.tsx | 52 ++ .../tools/gif-to-video-settings.tsx | 72 ++ .../tools/images-to-video-settings.tsx | 99 ++ .../tools/merge-videos-settings.tsx | 46 + .../tools/replace-audio-settings.tsx | 46 + .../tools/resize-video-settings.tsx | 121 +++ .../tools/reverse-video-settings.tsx | 54 ++ .../tools/rotate-video-settings.tsx | 75 ++ .../tools/stabilize-video-settings.tsx | 73 ++ .../components/tools/video-color-settings.tsx | 122 +++ .../tools/video-loudnorm-settings.tsx | 54 ++ .../tools/video-metadata-settings.tsx | 54 ++ .../components/tools/video-speed-settings.tsx | 82 ++ .../tools/video-to-frames-settings.tsx | 128 +++ .../tools/video-to-webp-settings.tsx | 116 +++ .../tools/watermark-video-settings.tsx | 145 +++ apps/web/src/lib/icon-map.ts | 24 + apps/web/src/lib/tool-display-modes.ts | 31 +- apps/web/src/lib/tool-registry.tsx | 152 +++- packages/media-engine/src/ffprobe.ts | 25 +- packages/media-engine/src/fonts.ts | 31 + packages/media-engine/src/index.ts | 1 + packages/shared/src/constants.ts | 244 ++++- packages/shared/src/i18n/ar.ts | 257 ++++++ packages/shared/src/i18n/de.ts | 257 ++++++ packages/shared/src/i18n/en.ts | 257 ++++++ packages/shared/src/i18n/es.ts | 257 ++++++ packages/shared/src/i18n/fr.ts | 257 ++++++ packages/shared/src/i18n/hi.ts | 257 ++++++ packages/shared/src/i18n/id.ts | 257 ++++++ packages/shared/src/i18n/it.ts | 257 ++++++ packages/shared/src/i18n/ja.ts | 257 ++++++ packages/shared/src/i18n/ko.ts | 257 ++++++ packages/shared/src/i18n/nl.ts | 257 ++++++ packages/shared/src/i18n/pl.ts | 257 ++++++ packages/shared/src/i18n/pt-BR.ts | 257 ++++++ packages/shared/src/i18n/ru.ts | 257 ++++++ packages/shared/src/i18n/sv.ts | 257 ++++++ packages/shared/src/i18n/th.ts | 257 ++++++ packages/shared/src/i18n/tr.ts | 257 ++++++ packages/shared/src/i18n/uk.ts | 257 ++++++ packages/shared/src/i18n/vi.ts | 257 ++++++ packages/shared/src/i18n/zh-CN.ts | 257 ++++++ packages/shared/src/i18n/zh-TW.ts | 257 ++++++ packages/shared/src/modality.ts | 1 + tests/fixtures/media/tiny-subs.mkv | Bin 0 -> 13050 bytes tests/fixtures/media/tiny.srt | 7 + tests/fixtures/media/tiny.vtt | 7 + tests/helpers/tool-default-settings.ts | 4 + tests/integration/aspect-pad.test.ts | 89 ++ tests/integration/blur-pad.test.ts | 84 ++ tests/integration/burn-subtitles.test.ts | 129 +++ tests/integration/change-fps.test.ts | 64 ++ tests/integration/crop-video.test.ts | 65 ++ tests/integration/embed-subtitles.test.ts | 68 ++ tests/integration/extract-subtitles.test.ts | 62 ++ tests/integration/gif-to-video.test.ts | 76 ++ tests/integration/images-to-video.test.ts | 79 ++ tests/integration/merge-videos.test.ts | 137 +++ .../integration/modality-media-kinds.test.ts | 34 + tests/integration/replace-audio.test.ts | 97 ++ tests/integration/resize-video.test.ts | 57 ++ tests/integration/reverse-video.test.ts | 63 ++ tests/integration/rotate-video.test.ts | 62 ++ tests/integration/stabilize-video.test.ts | 91 ++ tests/integration/video-color.test.ts | 49 + tests/integration/video-loudnorm.test.ts | 73 ++ tests/integration/video-metadata.test.ts | 54 ++ tests/integration/video-speed.test.ts | 97 ++ tests/integration/video-to-frames.test.ts | 82 ++ tests/integration/video-to-webp.test.ts | 55 ++ tests/integration/watermark-video.test.ts | 79 ++ tests/unit/api/aspect-tools.test.ts | 51 ++ tests/unit/api/video-speed.test.ts | 19 + tests/unit/media-engine/fonts.test.ts | 23 + 109 files changed, 12086 insertions(+), 838 deletions(-) create mode 100644 apps/api/src/routes/tools/aspect-pad.ts create mode 100644 apps/api/src/routes/tools/blur-pad.ts create mode 100644 apps/api/src/routes/tools/burn-subtitles.ts create mode 100644 apps/api/src/routes/tools/change-fps.ts create mode 100644 apps/api/src/routes/tools/crop-video.ts create mode 100644 apps/api/src/routes/tools/embed-subtitles.ts create mode 100644 apps/api/src/routes/tools/extract-subtitles.ts create mode 100644 apps/api/src/routes/tools/gif-to-video.ts create mode 100644 apps/api/src/routes/tools/images-to-video.ts create mode 100644 apps/api/src/routes/tools/merge-videos.ts create mode 100644 apps/api/src/routes/tools/replace-audio.ts create mode 100644 apps/api/src/routes/tools/resize-video.ts create mode 100644 apps/api/src/routes/tools/reverse-video.ts create mode 100644 apps/api/src/routes/tools/rotate-video.ts create mode 100644 apps/api/src/routes/tools/stabilize-video.ts create mode 100644 apps/api/src/routes/tools/video-color.ts create mode 100644 apps/api/src/routes/tools/video-loudnorm.ts create mode 100644 apps/api/src/routes/tools/video-metadata.ts create mode 100644 apps/api/src/routes/tools/video-speed.ts create mode 100644 apps/api/src/routes/tools/video-to-frames.ts create mode 100644 apps/api/src/routes/tools/video-to-webp.ts create mode 100644 apps/api/src/routes/tools/watermark-video.ts create mode 100644 apps/web/src/components/tools/aspect-pad-settings.tsx create mode 100644 apps/web/src/components/tools/blur-pad-settings.tsx create mode 100644 apps/web/src/components/tools/burn-subtitles-settings.tsx create mode 100644 apps/web/src/components/tools/change-fps-settings.tsx create mode 100644 apps/web/src/components/tools/crop-video-settings.tsx create mode 100644 apps/web/src/components/tools/embed-subtitles-settings.tsx create mode 100644 apps/web/src/components/tools/extract-subtitles-settings.tsx create mode 100644 apps/web/src/components/tools/gif-to-video-settings.tsx create mode 100644 apps/web/src/components/tools/images-to-video-settings.tsx create mode 100644 apps/web/src/components/tools/merge-videos-settings.tsx create mode 100644 apps/web/src/components/tools/replace-audio-settings.tsx create mode 100644 apps/web/src/components/tools/resize-video-settings.tsx create mode 100644 apps/web/src/components/tools/reverse-video-settings.tsx create mode 100644 apps/web/src/components/tools/rotate-video-settings.tsx create mode 100644 apps/web/src/components/tools/stabilize-video-settings.tsx create mode 100644 apps/web/src/components/tools/video-color-settings.tsx create mode 100644 apps/web/src/components/tools/video-loudnorm-settings.tsx create mode 100644 apps/web/src/components/tools/video-metadata-settings.tsx create mode 100644 apps/web/src/components/tools/video-speed-settings.tsx create mode 100644 apps/web/src/components/tools/video-to-frames-settings.tsx create mode 100644 apps/web/src/components/tools/video-to-webp-settings.tsx create mode 100644 apps/web/src/components/tools/watermark-video-settings.tsx create mode 100644 packages/media-engine/src/fonts.ts create mode 100644 tests/fixtures/media/tiny-subs.mkv create mode 100644 tests/fixtures/media/tiny.srt create mode 100644 tests/fixtures/media/tiny.vtt create mode 100644 tests/integration/aspect-pad.test.ts create mode 100644 tests/integration/blur-pad.test.ts create mode 100644 tests/integration/burn-subtitles.test.ts create mode 100644 tests/integration/change-fps.test.ts create mode 100644 tests/integration/crop-video.test.ts create mode 100644 tests/integration/embed-subtitles.test.ts create mode 100644 tests/integration/extract-subtitles.test.ts create mode 100644 tests/integration/gif-to-video.test.ts create mode 100644 tests/integration/images-to-video.test.ts create mode 100644 tests/integration/merge-videos.test.ts create mode 100644 tests/integration/modality-media-kinds.test.ts create mode 100644 tests/integration/replace-audio.test.ts create mode 100644 tests/integration/resize-video.test.ts create mode 100644 tests/integration/reverse-video.test.ts create mode 100644 tests/integration/rotate-video.test.ts create mode 100644 tests/integration/stabilize-video.test.ts create mode 100644 tests/integration/video-color.test.ts create mode 100644 tests/integration/video-loudnorm.test.ts create mode 100644 tests/integration/video-metadata.test.ts create mode 100644 tests/integration/video-speed.test.ts create mode 100644 tests/integration/video-to-frames.test.ts create mode 100644 tests/integration/video-to-webp.test.ts create mode 100644 tests/integration/watermark-video.test.ts create mode 100644 tests/unit/api/aspect-tools.test.ts create mode 100644 tests/unit/api/video-speed.test.ts create mode 100644 tests/unit/media-engine/fonts.test.ts diff --git a/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json index 88f526a9..54abc0aa 100644 --- a/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json +++ b/apps/api/drizzle-sqlite-legacy/meta/0000_snapshot.json @@ -1,67 +1,54 @@ { - "id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", - "prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335", - "version": "7", - "dialect": "postgresql", + "version": "6", + "dialect": "sqlite", + "id": "c7909605-aabf-4832-8ef8-9390c0f7c99a", + "prevId": "00000000-0000-0000-0000-000000000000", "tables": { - "public.api_keys": { + "api_keys": { "name": "api_keys", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true - }, - "key_prefix": { - "name": "key_prefix", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'Default API Key'" }, - "permissions": { - "name": "permissions", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "last_used_at": { "name": "last_used_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false } }, "indexes": {}, @@ -78,423 +65,120 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.audit_log": { - "name": "audit_log", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_username": { - "name": "actor_username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "target_id": { - "name": "target_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "details": { - "name": "details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "audit_log_actor_id_users_id_fk": { - "name": "audit_log_actor_id_users_id_fk", - "tableFrom": "audit_log", - "tableTo": "users", - "columnsFrom": ["actor_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.jobs": { + "jobs": { "name": "jobs", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tool_id": { - "name": "tool_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "pool": { - "name": "pool", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "type": { "name": "type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "status": { "name": "status", - "type": "job_status", - "typeSchema": "public", + "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'queued'" }, - "attempts": { - "name": "attempts", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, "progress": { "name": "progress", - "type": "jsonb", + "type": "real", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false, + "default": 0 }, - "input_refs": { - "name": "input_refs", - "type": "jsonb", + "input_files": { + "name": "input_files", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, - "output_refs": { - "name": "output_refs", - "type": "jsonb", + "output_path": { + "name": "output_path", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "settings": { "name": "settings", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "error": { "name": "error", - "type": "jsonb", + "type": "text", "primaryKey": false, - "notNull": false - }, - "bytes_in": { - "name": "bytes_in", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "bytes_out": { - "name": "bytes_out", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "duration_ms": { - "name": "duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": false + "notNull": false, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "completed_at": { "name": "completed_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "jobs_created_at_idx": { - "name": "jobs_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "jobs_status_idx": { - "name": "jobs_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "jobs_user_id_users_id_fk": { - "name": "jobs_user_id_users_id_fk", - "tableFrom": "jobs", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.pipelines": { - "name": "pipelines", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "steps": { - "name": "steps", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true + "notNull": false, + "autoincrement": false } }, "indexes": {}, - "foreignKeys": { - "pipelines_user_id_users_id_fk": { - "name": "pipelines_user_id_users_id_fk", - "tableFrom": "pipelines", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.roles": { - "name": "roles", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "permissions": { - "name": "permissions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "is_builtin": { - "name": "is_builtin", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "roles_created_by_users_id_fk": { - "name": "roles_created_by_users_id_fk", - "tableFrom": "roles", - "tableTo": "users", - "columnsFrom": ["created_by"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "roles_name_unique": { - "name": "roles_name_unique", - "nullsNotDistinct": false, - "columns": ["name"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { + "sessions": { "name": "sessions", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "expires_at": { "name": "expires_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false } }, "indexes": {}, @@ -511,297 +195,115 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.settings": { + "settings": { "name": "settings", - "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.teams": { - "name": "teams", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "teams_name_unique": { - "name": "teams_name_unique", - "nullsNotDistinct": false, - "columns": ["name"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_files": { - "name": "user_files", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "original_name": { - "name": "original_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "stored_name": { - "name": "stored_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "width": { - "name": "width", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "height": { - "name": "height", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "version": { - "name": "version", "type": "integer", "primaryKey": false, "notNull": true, - "default": 1 - }, - "parent_id": { - "name": "parent_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tool_chain": { - "name": "tool_chain", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true + "autoincrement": false } }, "indexes": {}, - "foreignKeys": { - "user_files_user_id_users_id_fk": { - "name": "user_files_user_id_users_id_fk", - "tableFrom": "user_files", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "checkConstraints": {} }, - "public.users": { + "users": { "name": "users", - "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true + "notNull": true, + "autoincrement": false }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": "'user'" }, - "team": { - "name": "team", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'Default'" - }, "must_change_password": { "name": "must_change_password", - "type": "boolean", + "type": "integer", "primaryKey": false, "notNull": true, + "autoincrement": false, "default": true }, - "auth_provider": { - "name": "auth_provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'local'" - }, - "external_id": { - "name": "external_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "autoincrement": false }, "updated_at": { "name": "updated_at", - "type": "timestamp with time zone", + "type": "integer", "primaryKey": false, - "notNull": true - }, - "analytics_enabled": { - "name": "analytics_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "analytics_consent_shown_at": { - "name": "analytics_consent_shown_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "analytics_consent_remind_at": { - "name": "analytics_consent_remind_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true, + "autoincrement": false } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { + "indexes": { "users_username_unique": { "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] + "columns": ["username"], + "isUnique": true } }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} } }, - "enums": { - "public.job_status": { - "name": "job_status", - "schema": "public", - "values": ["queued", "processing", "completed", "failed", "canceled"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, "views": {}, + "enums": {}, "_meta": { - "columns": {}, "schemas": {}, - "tables": {} + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} } } diff --git a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json index 6bf86838..88f526a9 100644 --- a/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json +++ b/apps/api/drizzle-sqlite-legacy/meta/0001_snapshot.json @@ -1,54 +1,67 @@ { - "version": "6", - "dialect": "sqlite", - "id": "91a14a95-bbcb-46ef-abe3-6d2f6fbc8458", - "prevId": "c7909605-aabf-4832-8ef8-9390c0f7c99a", + "id": "b83e1f2a-9c4d-4e7b-a1f3-8d2c6b5a4e90", + "prevId": "47a9d637-64e3-4cca-a916-cf42ea63b335", + "version": "7", + "dialect": "postgresql", "tables": { - "api_keys": { + "public.api_keys": { "name": "api_keys", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "key_hash": { "name": "key_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'Default API Key'" }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "last_used_at": { "name": "last_used_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "autoincrement": false + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": {}, @@ -65,165 +78,423 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "jobs": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { "name": "jobs", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'queued'" - }, - "progress": { - "name": "progress", - "type": "real", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "input_files": { - "name": "input_files", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "output_path": { - "name": "output_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "settings": { - "name": "settings", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pipelines": { - "name": "pipelines", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "steps": { - "name": "steps", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "sessions": { - "name": "sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, - "expires_at": { - "name": "expires_at", + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true } }, "indexes": {}, @@ -240,115 +511,297 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "settings": { + "public.settings": { "name": "settings", + "schema": "", "columns": { "key": { "name": "key", "type": "text", "primaryKey": true, - "notNull": true, - "autoincrement": false + "notNull": true }, "value": { "name": "value", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, - "checkConstraints": {} + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false }, - "users": { - "name": "users", + "public.teams": { + "name": "teams", + "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, "notNull": true, - "autoincrement": false + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true }, "username": { "name": "username", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": "'user'" }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, "must_change_password": { "name": "must_change_password", - "type": "integer", + "type": "boolean", "primaryKey": false, "notNull": true, - "autoincrement": false, "default": true }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false + "notNull": true }, "updated_at": { "name": "updated_at", - "type": "integer", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "users_username_unique": { - "name": "users_username_unique", - "columns": ["username"], - "isUnique": true + "notNull": true + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } }, - "internal": { - "indexes": {} + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} } } diff --git a/apps/api/src/lib/media-tool.ts b/apps/api/src/lib/media-tool.ts index 905f4103..905f4d2c 100644 --- a/apps/api/src/lib/media-tool.ts +++ b/apps/api/src/lib/media-tool.ts @@ -37,6 +37,40 @@ export interface MediaRunResult { durationS: number | null; } +/** Stages every ctx input into scratch (in--) and returns the paths. */ +export async function stageMediaInputs(ctx: ToolProcessCtxV2): Promise { + const dir = join(ctx.scratchDir, "media"); + await mkdir(dir, { recursive: true }); + const paths: string[] = []; + for (let i = 0; i < ctx.inputs.length; i++) { + const safe = ctx.inputs[i].filename.replace(/[^A-Za-z0-9._-]/g, "_"); + const p = join(dir, `in-${i}-${safe}`); + await writeFile(p, ctx.inputs[i].buffer); + paths.push(p); + } + return paths; +} + +/** Progress-mapped runFfmpeg (5..95%) against a known duration; 30 min default timeout. */ +export async function runFfmpegWithProgress( + ctx: ToolProcessCtxV2, + args: string[], + durationS: number | null, + opts: { timeoutMs?: number } = {}, +): Promise { + ctx.report(5, "Preparing"); + await runFfmpeg(args, { + signal: ctx.signal, + timeoutMs: opts.timeoutMs ?? 30 * 60_000, + onProgress: (p) => { + if (p.outTimeMs !== null && durationS) { + const pct = Math.min(95, 5 + Math.round((p.outTimeMs / (durationS * 1000)) * 90)); + ctx.report(pct, "Processing"); + } + }, + }); +} + /** * Stages the primary input in the scratch dir, probes it, runs ffmpeg with * progress mapped onto ctx.report (5..95%), and returns the output path for @@ -54,16 +88,11 @@ export async function runMediaTool( await writeFile(inPath, ctx.inputs[0].buffer); const info = await probeMedia(inPath); const outPath = join(dir, outName); - ctx.report(5, "Preparing"); - await runFfmpeg(argsFor(inPath, outPath, { durationS: info.durationS }), { - signal: ctx.signal, - timeoutMs: opts.timeoutMs ?? 30 * 60_000, - onProgress: (p) => { - if (p.outTimeMs !== null && info.durationS) { - const pct = Math.min(95, 5 + Math.round((p.outTimeMs / (info.durationS * 1000)) * 90)); - ctx.report(pct, "Processing"); - } - }, - }); + await runFfmpegWithProgress( + ctx, + argsFor(inPath, outPath, { durationS: info.durationS }), + info.durationS, + opts, + ); return { outPath, durationS: info.durationS }; } diff --git a/apps/api/src/modality/media-input.ts b/apps/api/src/modality/media-input.ts index 043a318b..6e9c3bfb 100644 --- a/apps/api/src/modality/media-input.ts +++ b/apps/api/src/modality/media-input.ts @@ -1,22 +1,34 @@ import { randomUUID } from "node:crypto"; import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { extname, join } from "node:path"; import { probeMedia } from "@snapotter/media-engine"; +import { SUBTITLE_INPUTS } from "@snapotter/shared"; import { env } from "../config.js"; import { type InputHandler, InputValidationError, type PreparedInput } from "./contract.js"; +export type MediaInputKind = "video" | "audio" | "image" | "subtitle"; + +const SUBTITLE_EXT_SET = new Set(SUBTITLE_INPUTS); +const MAX_SUBTITLE_BYTES = 1 * 1024 * 1024; // 1 MiB + /** - * Video/audio validation via capped ffprobe (spec 4.7). ffprobe needs a real - * file (mp4 moov atoms may trail), so the buffer lands in the scratch dir. + * Video/audio/image/subtitle validation via capped ffprobe (spec 4.7). + * ffprobe needs a real file (mp4 moov atoms may trail), so the buffer + * lands in the scratch dir for video/audio/image kinds. Subtitle kind + * skips ffprobe entirely (text-based, extension + content checks only). */ export class MediaInputHandler implements InputHandler { - constructor(private kind: "video" | "audio") {} + constructor(private kind: MediaInputKind) {} async prepare( raw: Buffer, filename: string, opts: { scratchDir: string }, ): Promise { + if (this.kind === "subtitle") { + return this.prepareSubtitle(raw, filename); + } + const probeDir = join(opts.scratchDir, `probe-${randomUUID()}`); await mkdir(probeDir, { recursive: true }); const probePath = join(probeDir, "input"); @@ -32,19 +44,28 @@ export class MediaInputHandler implements InputHandler { } const hasVideo = info.streams.some((s) => s.type === "video"); const hasAudio = info.streams.some((s) => s.type === "audio"); + + // ffprobe reports still images as single-frame video streams in + // *_pipe/image2 containers with no duration. + const IMAGE_CONTAINER_RE = + /(^|,)(png_pipe|image2|bmp_pipe|gif_pipe|jpeg_pipe|tiff_pipe|webp_pipe|svg_pipe)($|,)/; + const isStillImage = IMAGE_CONTAINER_RE.test(info.container) && info.durationS === null; + + if (this.kind === "image") { + // Image kind: accept still images only; reject audio-only and real videos + if (!hasVideo) { + throw new InputValidationError("File is not a still image"); + } + if (!isStillImage) { + throw new InputValidationError("File is not a still image"); + } + return { buffer: raw, filename }; + } + if (this.kind === "video" && !hasVideo) { throw new InputValidationError("File contains no video stream"); } - // ffprobe reports still images as single-frame video streams in - // *_pipe/image2 containers with no duration; a video tool must - // reject those (carry-forward from phase-3 input-handler review). - const IMAGE_CONTAINER_RE = - /(^|,)(png_pipe|image2|bmp_pipe|gif_pipe|jpeg_pipe|tiff_pipe|webp_pipe|svg_pipe)($|,)/; - if ( - this.kind === "video" && - IMAGE_CONTAINER_RE.test(info.container) && - info.durationS === null - ) { + if (this.kind === "video" && isStillImage) { throw new InputValidationError("File is a still image, not a video"); } if (this.kind === "audio" && !hasAudio) { @@ -72,4 +93,20 @@ export class MediaInputHandler implements InputHandler { await rm(probeDir, { recursive: true, force: true }).catch(() => {}); } } + + private prepareSubtitle(raw: Buffer, filename: string): PreparedInput { + const ext = extname(filename).toLowerCase(); + if (!SUBTITLE_EXT_SET.has(ext)) { + throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)"); + } + if (raw.length > MAX_SUBTITLE_BYTES) { + throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)"); + } + const text = new TextDecoder("utf-8", { fatal: false }).decode(raw); + const looksLikeSubtitle = /-->/.test(text) || /\[Script Info\]/i.test(text); + if (!looksLikeSubtitle) { + throw new InputValidationError("Not a valid subtitle file (.srt, .vtt, .ass)"); + } + return { buffer: raw, filename }; + } } diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index fab9e8fa..e7f14f4a 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -15,6 +15,7 @@ import { resolveToolPool, shouldSkipSyncWindow } from "../lib/pool.js"; import { type ReceivedUpload, receiveUpload } from "../lib/upload-stream.js"; import { InputValidationError } from "../modality/contract.js"; import { inputHandlerFor } from "../modality/input-handler.js"; +import { MediaInputHandler, type MediaInputKind } from "../modality/media-input.js"; import { getAuthUser } from "../plugins/auth.js"; import { updateSingleFileProgress } from "./progress.js"; @@ -69,6 +70,12 @@ export interface ToolRouteConfig { * inputRefs in arrival order. */ maxInputs?: number; + /** + * Per-position input kind overrides for mixed-input tools (e.g. video + + * subtitle). Input i validates with kind inputKinds[Math.min(i, len-1)]. + * When absent, the tool's modality drives a single handler as before. + */ + inputKinds?: ("video" | "audio" | "image" | "subtitle")[]; /** Zod schema that validates the settings JSON from the request. */ settingsSchema: z.ZodType; /** The processing function: takes input buffer + validated settings, returns output. */ @@ -100,6 +107,7 @@ export interface ToolRouteConfig { export interface AnyToolRouteConfig { toolId: string; maxInputs?: number; + inputKinds?: ("video" | "audio" | "image" | "subtitle")[]; settingsSchema: z.ZodType; process: ( inputBuffer: Buffer, @@ -294,6 +302,23 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } } + // Build per-position input handlers: when inputKinds is present, + // each position gets a MediaInputHandler for its kind; otherwise + // the tool's modality drives a single shared handler as before. + const kindHandlers: Map = new Map(); + function handlerForPosition(idx: number) { + if (config.inputKinds) { + const kind = config.inputKinds[Math.min(idx, config.inputKinds.length - 1)]; + let h = kindHandlers.get(kind); + if (!h) { + h = new MediaInputHandler(kind); + kindHandlers.set(kind, h); + } + return h; + } + return inputHandlerFor(modality); + } + // Prepare all files through the modality input handler const inputRefs: string[] = []; for (let i = 0; i < received.length; i++) { @@ -303,7 +328,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig let fname = upload.filename; try { - const prepared = await inputHandlerFor(modality).prepare(fileBuffer, fname, { + const prepared = await handlerForPosition(i).prepare(fileBuffer, fname, { scratchDir, lenient: config.skipStructuralValidation, }); diff --git a/apps/api/src/routes/tools/aspect-pad.ts b/apps/api/src/routes/tools/aspect-pad.ts new file mode 100644 index 00000000..f38e9736 --- /dev/null +++ b/apps/api/src/routes/tools/aspect-pad.ts @@ -0,0 +1,97 @@ +import { extname, join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const TARGETS = { + "16:9": [16, 9], + "9:16": [9, 16], + "1:1": [1, 1], + "4:3": [4, 3], + "3:4": [3, 4], +} as const; +type TargetKey = keyof typeof TARGETS; + +/** Output canvas for a source WxH fitted into ratio rw:rh (even dims). */ +export function canvasFor( + w: number, + h: number, + rw: number, + rh: number, +): { cw: number; ch: number } { + let cw: number; + let ch: number; + if (w * rh >= h * rw) { + cw = w; + ch = Math.round((w * rh) / rw); + } else { + ch = h; + cw = Math.round((h * rw) / rh); + } + cw += cw % 2; + ch += ch % 2; + return { cw, ch }; +} + +const settingsSchema = z.object({ + target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("9:16"), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default("#000000"), +}); + +export function registerAspectPad(app: FastifyInstance) { + createToolRoute(app, { + toolId: "aspect-pad", + settingsSchema, + process: async () => { + throw new Error("aspect-pad is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_padded${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const v = info.streams.find((s) => s.type === "video"); + const srcW = v?.width ?? 0; + const srcH = v?.height ?? 0; + + const [rw, rh] = TARGETS[settings.target as TargetKey]; + const { cw, ch } = canvasFor(srcW, srcH, rw, rh); + + const c = settings.color.replace("#", "0x"); + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + [ + "-i", + inPath, + "-vf", + `pad=${cw}:${ch}:(ow-iw)/2:(oh-ih)/2:color=${c}`, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + outPath, + ], + info.durationS, + ); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/blur-pad.ts b/apps/api/src/routes/tools/blur-pad.ts new file mode 100644 index 00000000..b04b12ec --- /dev/null +++ b/apps/api/src/routes/tools/blur-pad.ts @@ -0,0 +1,98 @@ +import { extname, join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const TARGETS = { + "16:9": [16, 9], + "9:16": [9, 16], + "1:1": [1, 1], + "4:3": [4, 3], + "3:4": [3, 4], +} as const; +type TargetKey = keyof typeof TARGETS; + +/** Output canvas for a source WxH fitted into ratio rw:rh (even dims). */ +export function canvasFor( + w: number, + h: number, + rw: number, + rh: number, +): { cw: number; ch: number } { + let cw: number; + let ch: number; + if (w * rh >= h * rw) { + cw = w; + ch = Math.round((w * rh) / rw); + } else { + ch = h; + cw = Math.round((h * rw) / rh); + } + cw += cw % 2; + ch += ch % 2; + return { cw, ch }; +} + +const settingsSchema = z.object({ + target: z.enum(["16:9", "9:16", "1:1", "4:3", "3:4"]).default("16:9"), + blur: z.number().min(2).max(50).default(20), +}); + +export function registerBlurPad(app: FastifyInstance) { + createToolRoute(app, { + toolId: "blur-pad", + settingsSchema, + process: async () => { + throw new Error("blur-pad is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_blurpad${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const v = info.streams.find((s) => s.type === "video"); + const srcW = v?.width ?? 0; + const srcH = v?.height ?? 0; + + const [rw, rh] = TARGETS[settings.target as TargetKey]; + const { cw, ch } = canvasFor(srcW, srcH, rw, rh); + + const filter = `[0:v]split[bg][fg];[bg]scale=${cw}:${ch}:force_original_aspect_ratio=increase,crop=${cw}:${ch},gblur=sigma=${settings.blur}[b];[b][fg]overlay=(W-w)/2:(H-h)/2[v]`; + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + [ + "-i", + inPath, + "-filter_complex", + filter, + "-map", + "[v]", + "-map", + "0:a?", + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + outPath, + ], + info.durationS, + ); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/burn-subtitles.ts b/apps/api/src/routes/tools/burn-subtitles.ts new file mode 100644 index 00000000..3681dbbe --- /dev/null +++ b/apps/api/src/routes/tools/burn-subtitles.ts @@ -0,0 +1,73 @@ +import { dirname, join } from "node:path"; +import { probeMedia, resolveEncoder, resolveFontFile } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + fontSize: z.number().int().min(8).max(72).default(24), +}); + +export function registerBurnSubtitles(app: FastifyInstance) { + createToolRoute(app, { + toolId: "burn-subtitles", + maxInputs: 2, + inputKinds: ["video", "subtitle"], + settingsSchema, + process: async () => { + throw new Error("burn-subtitles is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length !== 2) { + throw new InputValidationError("Provide a video file and a subtitle file"); + } + + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_subtitled.mp4`; + + const font = resolveFontFile(); + if (!font) { + throw new Error("No usable font found for subtitles (set SNAPOTTER_FONT_FILE)"); + } + + const paths = await stageMediaInputs(ctx); + const videoPath = paths[0]; + const subPath = paths[1]; + const info = await probeMedia(videoPath); + + const vf = `subtitles=${subPath}:fontsdir=${dirname(font.file)}:force_style='FontName=${font.family},FontSize=${settings.fontSize}'`; + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + [ + "-i", + videoPath, + "-vf", + vf, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + outPath, + ], + info.durationS, + ); + + return { + scratchPath: outPath, + filename: outName, + contentType: "video/mp4", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/change-fps.ts b/apps/api/src/routes/tools/change-fps.ts new file mode 100644 index 00000000..d4b0c64c --- /dev/null +++ b/apps/api/src/routes/tools/change-fps.ts @@ -0,0 +1,46 @@ +import { extname } from "node:path"; +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + fps: z.number().min(1).max(120).default(30), +}); + +export function registerChangeFps(app: FastifyInstance) { + createToolRoute(app, { + toolId: "change-fps", + settingsSchema, + process: async () => { + throw new Error("change-fps is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_fps${origExt}`; + const contentType = videoContentType(origExt); + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [ + "-i", + inPath, + "-vf", + `fps=${settings.fps}`, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + out, + ]); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/crop-video.ts b/apps/api/src/routes/tools/crop-video.ts new file mode 100644 index 00000000..4804c73e --- /dev/null +++ b/apps/api/src/routes/tools/crop-video.ts @@ -0,0 +1,68 @@ +import { extname, join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + width: z.number().int().min(16), + height: z.number().int().min(16), + x: z.number().int().min(0).default(0), + y: z.number().int().min(0).default(0), +}); + +export function registerCropVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "crop-video", + settingsSchema, + process: async () => { + throw new Error("crop-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_cropped${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const v = info.streams.find((s) => s.type === "video"); + const W = v?.width ?? 0; + const H = v?.height ?? 0; + + if (settings.x + settings.width > W || settings.y + settings.height > H) { + throw new InputValidationError( + `Crop rectangle ${settings.width}x${settings.height}+${settings.x}+${settings.y} exceeds video size ${W}x${H}`, + ); + } + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + [ + "-i", + inPath, + "-vf", + `crop=${settings.width}:${settings.height}:${settings.x}:${settings.y}`, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + outPath, + ], + info.durationS, + ); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/embed-subtitles.ts b/apps/api/src/routes/tools/embed-subtitles.ts new file mode 100644 index 00000000..e1b93bb3 --- /dev/null +++ b/apps/api/src/routes/tools/embed-subtitles.ts @@ -0,0 +1,76 @@ +import { basename, extname, join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + language: z + .string() + .regex(/^[a-z]{3}$/) + .default("eng"), +}); + +export function registerEmbedSubtitles(app: FastifyInstance) { + createToolRoute(app, { + toolId: "embed-subtitles", + maxInputs: 2, + inputKinds: ["video", "subtitle"], + settingsSchema, + process: async () => { + throw new Error("embed-subtitles is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length !== 2) { + throw new InputValidationError("Provide a video file and a subtitle file"); + } + + const settings = settingsSchema.parse(ctx.settings); + const base = basename(ctx.inputs[0].filename, extname(ctx.inputs[0].filename)); + + const srcExt = extname(ctx.inputs[0].filename).toLowerCase(); + const toMp4 = [".mp4", ".mov", ".m4v"].includes(srcExt); + const outExt = toMp4 ? ".mp4" : ".mkv"; + const scodec = toMp4 ? "mov_text" : "srt"; + + const outName = `${base}_subs${outExt}`; + const contentType = videoContentType(outExt); + + const paths = await stageMediaInputs(ctx); + const videoPath = paths[0]; + const subPath = paths[1]; + const info = await probeMedia(videoPath); + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + [ + "-i", + videoPath, + "-i", + subPath, + "-map", + "0", + "-map", + "1:0", + "-c", + "copy", + "-c:s", + scodec, + "-metadata:s:s:0", + `language=${settings.language}`, + outPath, + ], + info.durationS, + ); + + return { + scratchPath: outPath, + filename: outName, + contentType, + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/extract-subtitles.ts b/apps/api/src/routes/tools/extract-subtitles.ts new file mode 100644 index 00000000..37de5e94 --- /dev/null +++ b/apps/api/src/routes/tools/extract-subtitles.ts @@ -0,0 +1,42 @@ +import { join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerExtractSubtitles(app: FastifyInstance) { + createToolRoute(app, { + toolId: "extract-subtitles", + settingsSchema, + process: async () => { + throw new Error("extract-subtitles is v2-only"); + }, + processV2: async (ctx) => { + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.srt`; + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + + const outPath = join(ctx.scratchDir, "media", outName); + + try { + await runFfmpegWithProgress(ctx, ["-i", inPath, "-map", "0:s:0", outPath], info.durationS); + } catch (err) { + // A canceled job must not masquerade as a missing subtitle track + if (ctx.signal.aborted) throw err; + throw new InputValidationError("No subtitle track found in this video"); + } + + return { + scratchPath: outPath, + filename: outName, + contentType: "application/x-subrip", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/gif-to-video.ts b/apps/api/src/routes/tools/gif-to-video.ts new file mode 100644 index 00000000..18440324 --- /dev/null +++ b/apps/api/src/routes/tools/gif-to-video.ts @@ -0,0 +1,63 @@ +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + format: z.enum(["mp4", "webm"]).default("mp4"), +}); + +export function registerGifToVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "gif-to-video", + settingsSchema, + process: async () => { + throw new Error("gif-to-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.${settings.format}`; + const contentType = settings.format === "mp4" ? "video/mp4" : "video/webm"; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + if (settings.format === "webm") { + return [ + "-i", + inPath, + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-c:v", + resolveEncoder("vp9"), + "-b:v", + "0", + "-crf", + "32", + "-an", + out, + ]; + } + return [ + "-i", + inPath, + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + "-an", + out, + ]; + }); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/images-to-video.ts b/apps/api/src/routes/tools/images-to-video.ts new file mode 100644 index 00000000..cef8d478 --- /dev/null +++ b/apps/api/src/routes/tools/images-to-video.ts @@ -0,0 +1,79 @@ +import { join } from "node:path"; +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const DIMS: Record = { + "1080p": { w: 1920, h: 1080 }, + "720p": { w: 1280, h: 720 }, + square: { w: 1080, h: 1080 }, +}; + +const settingsSchema = z.object({ + secondsPerImage: z.number().min(0.5).max(10).default(2), + resolution: z.enum(["1080p", "720p", "square"]).default("720p"), + fps: z.number().int().min(10).max(60).default(30), +}); + +export function registerImagesToVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "images-to-video", + maxInputs: 60, + inputKinds: ["image"], + settingsSchema, + process: async () => { + throw new Error("images-to-video is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length < 2) { + throw new InputValidationError("Provide at least two images"); + } + + const settings = settingsSchema.parse(ctx.settings); + const { w: W, h: H } = DIMS[settings.resolution]; + + const paths = await stageMediaInputs(ctx); + + const parts: string[] = []; + const refs: string[] = []; + for (let i = 0; i < paths.length; i++) { + parts.push( + `[${i}:v]scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1[v${i}]`, + ); + refs.push(`[v${i}]`); + } + const filter = `${parts.join(";")};${refs.join("")}concat=n=${paths.length}:v=1:a=0[v]`; + + const args = [ + ...paths.flatMap((p) => ["-loop", "1", "-t", String(settings.secondsPerImage), "-i", p]), + "-filter_complex", + filter, + "-map", + "[v]", + "-r", + String(settings.fps), + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + join(ctx.scratchDir, "media", "slideshow.mp4"), + ]; + + const progressDuration = paths.length * settings.secondsPerImage; + await runFfmpegWithProgress(ctx, args, progressDuration); + + return { + scratchPath: join(ctx.scratchDir, "media", "slideshow.mp4"), + filename: "slideshow.mp4", + contentType: "video/mp4", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index ad87d2c4..1dd65ae6 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -4,12 +4,16 @@ import type { FastifyInstance } from "fastify"; import { db, schema } from "../../db/index.js"; import { registerColorAdjustments } from "./adjust-colors.js"; import { registerAiCanvasExpand } from "./ai-canvas-expand.js"; +import { registerAspectPad } from "./aspect-pad.js"; import { registerBarcodeRead } from "./barcode-read.js"; import { registerBeautify } from "./beautify.js"; import { registerBlurFaces } from "./blur-faces.js"; +import { registerBlurPad } from "./blur-pad.js"; import { registerBookletPdf } from "./booklet-pdf.js"; import { registerBorder } from "./border.js"; import { registerBulkRename } from "./bulk-rename.js"; +import { registerBurnSubtitles } from "./burn-subtitles.js"; +import { registerChangeFps } from "./change-fps.js"; import { registerCollage } from "./collage.js"; import { registerColorBlindness } from "./color-blindness.js"; import { registerColorPalette } from "./color-palette.js"; @@ -25,16 +29,20 @@ import { registerConvertAudio } from "./convert-audio.js"; import { registerConvertVideo } from "./convert-video.js"; import { registerCrop } from "./crop.js"; import { registerCropPdf } from "./crop-pdf.js"; +import { registerCropVideo } from "./crop-video.js"; import { registerCsvExcel } from "./csv-excel.js"; import { registerCsvJson } from "./csv-json.js"; import { registerEditMetadata } from "./edit-metadata.js"; +import { registerEmbedSubtitles } from "./embed-subtitles.js"; import { registerEnhanceFaces } from "./enhance-faces.js"; import { registerEraseObject } from "./erase-object.js"; import { registerExtractAudio } from "./extract-audio.js"; import { registerExtractPages } from "./extract-pages.js"; +import { registerExtractSubtitles } from "./extract-subtitles.js"; import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; import { registerFlattenPdf } from "./flatten-pdf.js"; +import { registerGifToVideo } from "./gif-to-video.js"; import { registerGifTools } from "./gif-tools.js"; import { registerGrayscalePdf } from "./grayscale-pdf.js"; import { registerHtmlToImage } from "./html-to-image.js"; @@ -42,12 +50,14 @@ import { registerHtmlToPdf } from "./html-to-pdf.js"; import { registerImageEnhancement } from "./image-enhancement.js"; import { registerImageToBase64 } from "./image-to-base64.js"; import { registerImageToPdf } from "./image-to-pdf.js"; +import { registerImagesToVideo } from "./images-to-video.js"; import { registerInfo } from "./info.js"; import { registerJsonXml } from "./json-xml.js"; import { registerLinearizePdf } from "./linearize-pdf.js"; import { registerMarkdownToPdf } from "./markdown-to-pdf.js"; import { registerMemeGenerator } from "./meme-generator.js"; import { registerMergePdf } from "./merge-pdf.js"; +import { registerMergeVideos } from "./merge-videos.js"; import { registerMuteVideo } from "./mute-video.js"; import { registerNoiseRemoval } from "./noise-removal.js"; import { registerNupPdf } from "./nup-pdf.js"; @@ -68,16 +78,21 @@ import { registerRedactPdf } from "./redact-pdf.js"; import { registerRemoveBackground } from "./remove-background.js"; import { registerRemovePages } from "./remove-pages.js"; import { registerRepairPdf } from "./repair-pdf.js"; +import { registerReplaceAudio } from "./replace-audio.js"; import { registerReplaceColor } from "./replace-color.js"; import { registerResize } from "./resize.js"; +import { registerResizeVideo } from "./resize-video.js"; import { registerRestorePhoto } from "./restore-photo.js"; +import { registerReverseVideo } from "./reverse-video.js"; import { registerRotate } from "./rotate.js"; import { registerRotatePdf } from "./rotate-pdf.js"; +import { registerRotateVideo } from "./rotate-video.js"; import { registerSharpening } from "./sharpening.js"; import { registerSmartCrop } from "./smart-crop.js"; import { registerSplit } from "./split.js"; import { registerSplitCsv } from "./split-csv.js"; import { registerSplitPdf } from "./split-pdf.js"; +import { registerStabilizeVideo } from "./stabilize-video.js"; import { registerStitch } from "./stitch.js"; import { registerStripMetadata } from "./strip-metadata.js"; import { registerSvgToRaster } from "./svg-to-raster.js"; @@ -88,10 +103,17 @@ import { registerTrimVideo } from "./trim-video.js"; import { registerUnlockPdf } from "./unlock-pdf.js"; import { registerUpscale } from "./upscale.js"; import { registerVectorize } from "./vectorize.js"; +import { registerVideoColor } from "./video-color.js"; +import { registerVideoLoudnorm } from "./video-loudnorm.js"; +import { registerVideoMetadata } from "./video-metadata.js"; +import { registerVideoSpeed } from "./video-speed.js"; +import { registerVideoToFrames } from "./video-to-frames.js"; import { registerVideoToGif } from "./video-to-gif.js"; +import { registerVideoToWebp } from "./video-to-webp.js"; import { registerWatermarkImage } from "./watermark-image.js"; import { registerWatermarkPdf } from "./watermark-pdf.js"; import { registerWatermarkText } from "./watermark-text.js"; +import { registerWatermarkVideo } from "./watermark-video.js"; import { registerWordToPdf } from "./word-to-pdf.js"; /** @@ -178,11 +200,33 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "color-blindness", register: registerColorBlindness }, // Video - { id: "convert-video", register: registerConvertVideo }, + { id: "aspect-pad", register: registerAspectPad }, + { id: "blur-pad", register: registerBlurPad }, + { id: "burn-subtitles", register: registerBurnSubtitles }, + { id: "change-fps", register: registerChangeFps }, { id: "compress-video", register: registerCompressVideo }, - { id: "trim-video", register: registerTrimVideo }, + { id: "convert-video", register: registerConvertVideo }, + { id: "crop-video", register: registerCropVideo }, + { id: "embed-subtitles", register: registerEmbedSubtitles }, + { id: "extract-subtitles", register: registerExtractSubtitles }, + { id: "gif-to-video", register: registerGifToVideo }, + { id: "images-to-video", register: registerImagesToVideo }, + { id: "merge-videos", register: registerMergeVideos }, { id: "mute-video", register: registerMuteVideo }, + { id: "replace-audio", register: registerReplaceAudio }, + { id: "resize-video", register: registerResizeVideo }, + { id: "reverse-video", register: registerReverseVideo }, + { id: "rotate-video", register: registerRotateVideo }, + { id: "stabilize-video", register: registerStabilizeVideo }, + { id: "trim-video", register: registerTrimVideo }, + { id: "video-color", register: registerVideoColor }, + { id: "video-loudnorm", register: registerVideoLoudnorm }, + { id: "video-metadata", register: registerVideoMetadata }, + { id: "video-speed", register: registerVideoSpeed }, + { id: "video-to-frames", register: registerVideoToFrames }, { id: "video-to-gif", register: registerVideoToGif }, + { id: "video-to-webp", register: registerVideoToWebp }, + { id: "watermark-video", register: registerWatermarkVideo }, // Audio { id: "convert-audio", register: registerConvertAudio }, diff --git a/apps/api/src/routes/tools/merge-videos.ts b/apps/api/src/routes/tools/merge-videos.ts new file mode 100644 index 00000000..7e374c56 --- /dev/null +++ b/apps/api/src/routes/tools/merge-videos.ts @@ -0,0 +1,90 @@ +import { join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerMergeVideos(app: FastifyInstance) { + createToolRoute(app, { + toolId: "merge-videos", + maxInputs: 10, + settingsSchema, + process: async () => { + throw new Error("merge-videos is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length < 2) { + throw new InputValidationError("Merging needs at least two videos"); + } + + const paths = await stageMediaInputs(ctx); + const probes = []; + for (const p of paths) { + probes.push(await probeMedia(p)); + } + + const first = probes[0].streams.find((s) => s.type === "video"); + if (!first?.width || !first?.height) { + throw new InputValidationError("First input has no video stream"); + } + + // Canvas: first video's dims, rounded to even + const W = first.width - (first.width % 2); + const H = first.height - (first.height % 2); + const totalS = probes.reduce((a, p) => a + (p.durationS ?? 0), 0) || null; + + // Build per-input normalize chains + concat + const parts: string[] = []; + const refs: string[] = []; + for (let i = 0; i < paths.length; i++) { + parts.push( + `[${i}:v]scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v${i}]`, + ); + const hasAudio = probes[i].streams.some((s) => s.type === "audio"); + if (hasAudio) { + parts.push(`[${i}:a]aresample=48000,aformat=channel_layouts=stereo[a${i}]`); + } else { + const d = probes[i].durationS ?? 1; + parts.push(`anullsrc=r=48000:cl=stereo,atrim=duration=${d}[a${i}]`); + } + refs.push(`[v${i}][a${i}]`); + } + + const filter = `${parts.join(";")};${refs.join("")}concat=n=${paths.length}:v=1:a=1[v][a]`; + const outPath = join(ctx.scratchDir, "media", "merged.mp4"); + + const args = [ + ...paths.flatMap((p) => ["-i", p]), + "-filter_complex", + filter, + "-map", + "[v]", + "-map", + "[a]", + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + resolveEncoder("aac"), + outPath, + ]; + + await runFfmpegWithProgress(ctx, args, totalS); + + return { + scratchPath: outPath, + filename: "merged.mp4", + contentType: "video/mp4", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/replace-audio.ts b/apps/api/src/routes/tools/replace-audio.ts new file mode 100644 index 00000000..e6b884f5 --- /dev/null +++ b/apps/api/src/routes/tools/replace-audio.ts @@ -0,0 +1,73 @@ +import { basename, extname, join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerReplaceAudio(app: FastifyInstance) { + createToolRoute(app, { + toolId: "replace-audio", + maxInputs: 2, + inputKinds: ["video", "audio"], + settingsSchema, + process: async () => { + throw new Error("replace-audio is v2-only"); + }, + processV2: async (ctx) => { + if (ctx.inputs.length !== 2) { + throw new InputValidationError("Provide a video and an audio file"); + } + + const paths = await stageMediaInputs(ctx); + const videoProbe = await probeMedia(paths[0]); + const audioProbe = await probeMedia(paths[1]); + + const hasVideo = videoProbe.streams.some((s) => s.type === "video"); + if (!hasVideo) { + throw new InputValidationError("First file must be a video"); + } + + const hasAudio = audioProbe.streams.some((s) => s.type === "audio"); + if (!hasAudio) { + throw new InputValidationError("Second file must be an audio file"); + } + + // vp8/vp9 in webm cannot be muxed into mp4; keep the source container + const vcodec = videoProbe.streams.find((s) => s.type === "video")?.codec ?? ""; + const ext = ["vp8", "vp9"].includes(vcodec) ? ".webm" : ".mp4"; + const videoBase = basename(ctx.inputs[0].filename, extname(ctx.inputs[0].filename)); + const outName = `${videoBase}_newaudio${ext}`; + const outPath = join(ctx.scratchDir, "media", outName); + + const args = [ + "-i", + paths[0], + "-i", + paths[1], + "-map", + "0:v:0", + "-map", + "1:a:0", + "-c:v", + "copy", + "-c:a", + resolveEncoder("aac"), + "-shortest", + outPath, + ]; + + const totalS = videoProbe.durationS; + await runFfmpegWithProgress(ctx, args, totalS); + + return { + scratchPath: outPath, + filename: outName, + contentType: videoContentType(ext), + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/resize-video.ts b/apps/api/src/routes/tools/resize-video.ts new file mode 100644 index 00000000..f25f291b --- /dev/null +++ b/apps/api/src/routes/tools/resize-video.ts @@ -0,0 +1,71 @@ +import { extname } from "node:path"; +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const PRESET_HEIGHTS: Record = { + "2160p": 2160, + "1440p": 1440, + "1080p": 1080, + "720p": 720, + "480p": 480, + "360p": 360, +}; + +const settingsSchema = z + .object({ + width: z.number().int().min(16).max(7680).optional(), + height: z.number().int().min(16).max(4320).optional(), + preset: z.enum(["custom", "2160p", "1440p", "1080p", "720p", "480p", "360p"]).default("custom"), + }) + .refine((s) => s.preset !== "custom" || s.width !== undefined || s.height !== undefined, { + message: "Set a width, height, or preset", + }); + +export function registerResizeVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "resize-video", + settingsSchema, + process: async () => { + throw new Error("resize-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_resized${origExt}`; + const contentType = videoContentType(origExt); + + let w: number | string; + let h: number | string; + if (settings.preset !== "custom") { + w = -2; + h = PRESET_HEIGHTS[settings.preset]; + } else { + w = settings.width ?? -2; + h = settings.height ?? -2; + } + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [ + "-i", + inPath, + "-vf", + `scale=${w}:${h}:flags=lanczos`, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + out, + ]); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/reverse-video.ts b/apps/api/src/routes/tools/reverse-video.ts new file mode 100644 index 00000000..9862b725 --- /dev/null +++ b/apps/api/src/routes/tools/reverse-video.ts @@ -0,0 +1,80 @@ +import { extname, join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerReverseVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "reverse-video", + settingsSchema, + process: async () => { + throw new Error("reverse-video is v2-only"); + }, + processV2: async (ctx) => { + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_reversed${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + + if ((info.durationS ?? 0) > 300) { + throw new InputValidationError("Reverse is limited to clips up to 5 minutes"); + } + + const hasAudio = info.streams.some((s) => s.type === "audio"); + const outPath = join(ctx.scratchDir, "media", outName); + + let args: string[]; + + if (hasAudio) { + args = [ + "-i", + inPath, + "-vf", + "reverse", + "-af", + "areverse", + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + resolveEncoder("aac"), + outPath, + ]; + } else { + args = [ + "-i", + inPath, + "-vf", + "reverse", + "-an", + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + outPath, + ]; + } + + await runFfmpegWithProgress(ctx, args, info.durationS); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/rotate-video.ts b/apps/api/src/routes/tools/rotate-video.ts new file mode 100644 index 00000000..1b77c47a --- /dev/null +++ b/apps/api/src/routes/tools/rotate-video.ts @@ -0,0 +1,54 @@ +import { extname } from "node:path"; +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const VF_MAP: Record = { + cw90: "transpose=1", + ccw90: "transpose=2", + "180": "transpose=1,transpose=1", + hflip: "hflip", + vflip: "vflip", +}; + +const settingsSchema = z.object({ + transform: z.enum(["cw90", "ccw90", "180", "hflip", "vflip"]), +}); + +export function registerRotateVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "rotate-video", + settingsSchema, + process: async () => { + throw new Error("rotate-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_rotated${origExt}`; + const contentType = videoContentType(origExt); + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [ + "-i", + inPath, + "-vf", + VF_MAP[settings.transform], + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + out, + ]); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/stabilize-video.ts b/apps/api/src/routes/tools/stabilize-video.ts new file mode 100644 index 00000000..4badeea0 --- /dev/null +++ b/apps/api/src/routes/tools/stabilize-video.ts @@ -0,0 +1,69 @@ +import { extname, join } from "node:path"; +import { probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + smoothing: z.number().int().min(5).max(60).default(15), +}); + +export function registerStabilizeVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "stabilize-video", + settingsSchema, + process: async () => { + throw new Error("stabilize-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_stabilized${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const trf = join(ctx.scratchDir, "media", "stab.trf"); + const nullOut = join(ctx.scratchDir, "media", "null.out"); + + // Pass 1: motion analysis (no progress mapping; stdout carries -progress pipe:1) + ctx.report(5, "Analyzing"); + await runFfmpeg( + ["-i", inPath, "-an", "-vf", `vidstabdetect=result=${trf}`, "-f", "null", nullOut], + { + signal: ctx.signal, + timeoutMs: 30 * 60_000, + }, + ); + + // Pass 2: stabilization with re-encode + ctx.report(50, "Stabilizing"); + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + [ + "-i", + inPath, + "-vf", + `vidstabtransform=input=${trf}:smoothing=${settings.smoothing}`, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + outPath, + ], + info.durationS, + ); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-color.ts b/apps/api/src/routes/tools/video-color.ts new file mode 100644 index 00000000..4f51448c --- /dev/null +++ b/apps/api/src/routes/tools/video-color.ts @@ -0,0 +1,49 @@ +import { extname } from "node:path"; +import { resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + brightness: z.number().min(-1).max(1).default(0), + contrast: z.number().min(0).max(4).default(1), + saturation: z.number().min(0).max(3).default(1), + gamma: z.number().min(0.1).max(10).default(1), +}); + +export function registerVideoColor(app: FastifyInstance) { + createToolRoute(app, { + toolId: "video-color", + settingsSchema, + process: async () => { + throw new Error("video-color is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_color${origExt}`; + const contentType = videoContentType(origExt); + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [ + "-i", + inPath, + "-vf", + `eq=brightness=${settings.brightness}:contrast=${settings.contrast}:saturation=${settings.saturation}:gamma=${settings.gamma}`, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + out, + ]); + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-loudnorm.ts b/apps/api/src/routes/tools/video-loudnorm.ts new file mode 100644 index 00000000..94847b4c --- /dev/null +++ b/apps/api/src/routes/tools/video-loudnorm.ts @@ -0,0 +1,52 @@ +import { extname, join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerVideoLoudnorm(app: FastifyInstance) { + createToolRoute(app, { + toolId: "video-loudnorm", + settingsSchema, + process: async () => { + throw new Error("video-loudnorm is v2-only"); + }, + processV2: async (ctx) => { + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_loudnorm${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + + if (!info.streams.some((s) => s.type === "audio")) { + throw new InputValidationError("This video has no audio track to normalize"); + } + + const outPath = join(ctx.scratchDir, "media", outName); + + const args = [ + "-i", + inPath, + "-af", + "loudnorm=I=-16:TP=-1.5:LRA=11", + "-c:v", + "copy", + "-c:a", + resolveEncoder("aac"), + "-b:a", + "192k", + outPath, + ]; + + await runFfmpegWithProgress(ctx, args, info.durationS); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-metadata.ts b/apps/api/src/routes/tools/video-metadata.ts new file mode 100644 index 00000000..f75083f0 --- /dev/null +++ b/apps/api/src/routes/tools/video-metadata.ts @@ -0,0 +1,48 @@ +import { extname, join } from "node:path"; +import { probeMedia } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({}); + +export function registerVideoMetadata(app: FastifyInstance) { + createToolRoute(app, { + toolId: "video-metadata", + settingsSchema, + process: async () => { + throw new Error("video-metadata is v2-only"); + }, + processV2: async (ctx) => { + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_clean${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + + const outPath = join(ctx.scratchDir, "media", outName); + await runFfmpegWithProgress( + ctx, + ["-i", inPath, "-map", "0", "-map_metadata", "-1", "-c", "copy", outPath], + info.durationS, + ); + + return { + scratchPath: outPath, + filename: outName, + contentType, + resultPayload: { + metadata: { + container: info.container, + durationS: info.durationS, + bitrateKbps: info.bitrateKbps, + streams: info.streams, + }, + }, + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-speed.ts b/apps/api/src/routes/tools/video-speed.ts new file mode 100644 index 00000000..fc0c5520 --- /dev/null +++ b/apps/api/src/routes/tools/video-speed.ts @@ -0,0 +1,101 @@ +import { extname, join } from "node:path"; +import { probeMedia, resolveEncoder } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + factor: z.number().min(0.25).max(4).default(2), + keepPitch: z.boolean().default(true), +}); + +/** atempo only accepts 0.5..100; chain factors of 0.5 until in range. */ +export function buildAtempoChain(factor: number): string { + const parts: string[] = []; + let f = factor; + while (f < 0.5) { + parts.push("atempo=0.5"); + f /= 0.5; + } + parts.push(`atempo=${f}`); + return parts.join(","); +} + +export function registerVideoSpeed(app: FastifyInstance) { + createToolRoute(app, { + toolId: "video-speed", + settingsSchema, + process: async () => { + throw new Error("video-speed is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_speed${origExt}`; + const contentType = videoContentType(origExt); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const hasAudio = info.streams.some((s) => s.type === "audio"); + + let args: string[]; + + if (hasAudio) { + const audioStream = info.streams.find((s) => s.type === "audio"); + const sr = audioStream?.sampleRate ?? 48000; + let audioChain: string; + if (settings.keepPitch) { + audioChain = buildAtempoChain(settings.factor); + } else { + audioChain = `asetrate=${sr}*${settings.factor},aresample=${sr}`; + } + + args = [ + "-i", + inPath, + "-filter_complex", + `[0:v]setpts=PTS/${settings.factor}[v];[0:a]${audioChain}[a]`, + "-map", + "[v]", + "-map", + "[a]", + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + resolveEncoder("aac"), + ]; + } else { + args = [ + "-i", + inPath, + "-vf", + `setpts=PTS/${settings.factor}`, + "-an", + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + ]; + } + + const outPath = join(ctx.scratchDir, "media", outName); + args.push(outPath); + + await runFfmpegWithProgress(ctx, args, info.durationS); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-to-frames.ts b/apps/api/src/routes/tools/video-to-frames.ts new file mode 100644 index 00000000..e56a7781 --- /dev/null +++ b/apps/api/src/routes/tools/video-to-frames.ts @@ -0,0 +1,126 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { probeMedia, runFfmpeg } from "@snapotter/media-engine"; +import archiver from "archiver"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { stageMediaInputs } from "../../lib/media-tool.js"; +import { InputValidationError } from "../../modality/contract.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z + .object({ + mode: z.enum(["all", "nth", "timestamps"]).default("all"), + n: z.number().int().min(2).max(1000).default(10), + timestamps: z.string().max(500).default(""), + format: z.enum(["png", "jpg"]).default("png"), + }) + .refine((s) => s.mode !== "timestamps" || s.timestamps.trim().length > 0, { + message: "Provide timestamps", + }); + +export function registerVideoToFrames(app: FastifyInstance) { + createToolRoute(app, { + toolId: "video-to-frames", + settingsSchema, + process: async () => { + throw new Error("video-to-frames is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + + const [inPath] = await stageMediaInputs(ctx); + const info = await probeMedia(inPath); + const durationS = info.durationS ?? 0; + + const framesDir = join(ctx.scratchDir, "media", "frames"); + await mkdir(framesDir, { recursive: true }); + + if (settings.mode === "all") { + // Guard: estimate frames as durationS * 30 (safe ceiling; probe lacks fps) + if (durationS * 30 > 2000) { + throw new InputValidationError("Too many frames; use every-Nth or timestamps mode"); + } + ctx.report(5, "Extracting frames"); + await runFfmpeg(["-i", inPath, join(framesDir, `frame-%06d.${settings.format}`)], { + signal: ctx.signal, + timeoutMs: 30 * 60_000, + }); + } else if (settings.mode === "nth") { + ctx.report(5, "Extracting frames"); + await runFfmpeg( + [ + "-i", + inPath, + "-vf", + `select=not(mod(n\\,${settings.n}))`, + "-fps_mode", + "vfr", + join(framesDir, `frame-%06d.${settings.format}`), + ], + { signal: ctx.signal, timeoutMs: 30 * 60_000 }, + ); + } else { + // timestamps mode + const stamps = settings.timestamps.split(",").map((t) => Number(t.trim())); + for (const t of stamps) { + if (Number.isNaN(t) || t < 0) { + throw new InputValidationError( + `Invalid timestamp "${t}": each timestamp must be a non-negative number`, + ); + } + if (t > durationS) { + throw new InputValidationError( + `Timestamp ${t}s is beyond the video duration of ${durationS.toFixed(1)}s`, + ); + } + } + if (stamps.length > 50) { + throw new InputValidationError("Too many timestamps; maximum is 50 per extraction"); + } + for (let idx = 0; idx < stamps.length; idx++) { + const t = stamps[idx]; + const frameName = `frame-${String(idx).padStart(3, "0")}.${settings.format}`; + ctx.report( + Math.min(90, 5 + Math.round(((idx + 1) / stamps.length) * 85)), + `Extracting frame ${idx + 1}/${stamps.length}`, + ); + await runFfmpeg( + ["-ss", String(t), "-i", inPath, "-frames:v", "1", join(framesDir, frameName)], + { signal: ctx.signal, timeoutMs: 60_000 }, + ); + } + } + + // Collect produced frames + const files = (await readdir(framesDir)).filter((f) => f.endsWith(`.${settings.format}`)); + files.sort(); + if (files.length === 0) { + throw new Error("No frames extracted"); + } + + // Zip frames (same pattern as split-pdf.ts) + ctx.report(92, "Creating archive"); + const zipPath = join(ctx.scratchDir, "media", `${base}_frames.zip`); + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 5 } }); + output.on("close", () => resolve()); + archive.on("error", (err: Error) => reject(err)); + archive.pipe(output); + for (const f of files) { + archive.file(join(framesDir, f), { name: f }); + } + void archive.finalize(); + }); + + return { + scratchPath: zipPath, + filename: `${base}_frames.zip`, + contentType: "application/zip", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/video-to-webp.ts b/apps/api/src/routes/tools/video-to-webp.ts new file mode 100644 index 00000000..0568a758 --- /dev/null +++ b/apps/api/src/routes/tools/video-to-webp.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + fps: z.number().int().min(1).max(30).default(12), + width: z.number().int().min(16).max(1920).default(480), + quality: z.number().int().min(1).max(100).default(75), + loop: z.boolean().default(true), +}); + +export function registerVideoToWebp(app: FastifyInstance) { + createToolRoute(app, { + toolId: "video-to-webp", + settingsSchema, + process: async () => { + throw new Error("video-to-webp is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}.webp`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => { + return [ + "-i", + inPath, + "-vf", + `fps=${settings.fps},scale=${settings.width}:-2:flags=lanczos`, + "-c:v", + "libwebp_anim", + "-quality", + String(settings.quality), + "-loop", + settings.loop ? "0" : "1", + "-an", + out, + ]; + }); + return { scratchPath: outPath, filename: outName, contentType: "image/webp" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/watermark-video.ts b/apps/api/src/routes/tools/watermark-video.ts new file mode 100644 index 00000000..42547ef4 --- /dev/null +++ b/apps/api/src/routes/tools/watermark-video.ts @@ -0,0 +1,80 @@ +import { writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { resolveEncoder, resolveFontFile } from "@snapotter/media-engine"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { runMediaTool, videoContentType } from "../../lib/media-tool.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + text: z.string().min(1).max(200), + position: z.enum(["tl", "tc", "tr", "l", "c", "r", "bl", "bc", "br"]).default("br"), + fontSize: z.number().int().min(8).max(120).default(36), + opacity: z.number().min(0.05).max(1).default(0.5), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default("#ffffff"), +}); + +const POS: Record = { + tl: "x=24:y=24", + tc: "x=(w-tw)/2:y=24", + tr: "x=w-tw-24:y=24", + l: "x=24:y=(h-th)/2", + c: "x=(w-tw)/2:y=(h-th)/2", + r: "x=w-tw-24:y=(h-th)/2", + bl: "x=24:y=h-th-24", + bc: "x=(w-tw)/2:y=h-th-24", + br: "x=w-tw-24:y=h-th-24", +}; + +export function registerWatermarkVideo(app: FastifyInstance) { + createToolRoute(app, { + toolId: "watermark-video", + settingsSchema, + process: async () => { + throw new Error("watermark-video is v2-only"); + }, + processV2: async (ctx) => { + const settings = settingsSchema.parse(ctx.settings); + const origExt = extname(ctx.inputs[0].filename) || ".mp4"; + const base = ctx.inputs[0].filename.replace(/\.[^.]+$/, ""); + const outName = `${base}_watermarked${origExt}`; + const contentType = videoContentType(origExt); + + const font = resolveFontFile(); + if (!font) { + throw new Error("No usable font found for watermarking (set SNAPOTTER_FONT_FILE)"); + } + + const textFile = join(ctx.scratchDir, "wm-text.txt"); + await writeFile(textFile, settings.text, "utf8"); + + const color = `${settings.color.replace("#", "0x")}@${settings.opacity.toFixed(2)}`; + + // expansion=none: user text is literal; %{...} sequences must not expand + const vf = `drawtext=fontfile=${font.file}:textfile=${textFile}:fontsize=${settings.fontSize}:fontcolor=${color}:${POS[settings.position]}:expansion=none`; + + const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => [ + "-i", + inPath, + "-vf", + vf, + "-c:v", + resolveEncoder("h264"), + "-crf", + "20", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-c:a", + "copy", + out, + ]); + + return { scratchPath: outPath, filename: outName, contentType }; + }, + }); +} diff --git a/apps/web/src/components/tools/aspect-pad-settings.tsx b/apps/web/src/components/tools/aspect-pad-settings.tsx new file mode 100644 index 00000000..5efdd816 --- /dev/null +++ b/apps/web/src/components/tools/aspect-pad-settings.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Target = "16:9" | "9:16" | "1:1" | "4:3" | "3:4"; + +export function AspectPadSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["aspect-pad"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("aspect-pad"); + + const [target, setTarget] = useState("9:16"); + const [color, setColor] = useState("#000000"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { target, color }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +
+ + setColor(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s.colorHint}

+
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/blur-pad-settings.tsx b/apps/web/src/components/tools/blur-pad-settings.tsx new file mode 100644 index 00000000..b8ec1761 --- /dev/null +++ b/apps/web/src/components/tools/blur-pad-settings.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Target = "16:9" | "9:16" | "1:1" | "4:3" | "3:4"; + +export function BlurPadSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["blur-pad"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("blur-pad"); + + const [target, setTarget] = useState("16:9"); + const [blur, setBlur] = useState(20); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { target, blur }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ +
+ + setBlur(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/burn-subtitles-settings.tsx b/apps/web/src/components/tools/burn-subtitles-settings.tsx new file mode 100644 index 00000000..924de271 --- /dev/null +++ b/apps/web/src/components/tools/burn-subtitles-settings.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +export function BurnSubtitlesSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["burn-subtitles"]; + const { files } = useFileStore(); + const { processFiles, processing, error, progress } = useToolProcessor("burn-subtitles"); + + const [fontSize, setFontSize] = useState(24); + + const hasEnough = files.length >= 2; + + const handleProcess = () => { + processFiles(files, { fontSize }); + }; + + return ( +
+
+ + setFontSize(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/change-fps-settings.tsx b/apps/web/src/components/tools/change-fps-settings.tsx new file mode 100644 index 00000000..1057a32b --- /dev/null +++ b/apps/web/src/components/tools/change-fps-settings.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function ChangeFpsSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["change-fps"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("change-fps"); + + const [fps, setFps] = useState(30); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { fps }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setFps(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/crop-video-settings.tsx b/apps/web/src/components/tools/crop-video-settings.tsx new file mode 100644 index 00000000..1147cf46 --- /dev/null +++ b/apps/web/src/components/tools/crop-video-settings.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function CropVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["crop-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("crop-video"); + + const [width, setWidth] = useState(""); + const [height, setHeight] = useState(""); + const [x, setX] = useState(0); + const [y, setY] = useState(0); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + const dimsValid = width !== "" && height !== ""; + + const handleProcess = () => { + const settings = { width, height, x, y }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setWidth(e.target.value === "" ? "" : Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setHeight(e.target.value === "" ? "" : Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setX(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setY(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/embed-subtitles-settings.tsx b/apps/web/src/components/tools/embed-subtitles-settings.tsx new file mode 100644 index 00000000..aa268ca8 --- /dev/null +++ b/apps/web/src/components/tools/embed-subtitles-settings.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +export function EmbedSubtitlesSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["embed-subtitles"]; + const { files } = useFileStore(); + const { processFiles, processing, error, progress } = useToolProcessor("embed-subtitles"); + + const [language, setLanguage] = useState("eng"); + + const hasEnough = files.length >= 2; + + const handleProcess = () => { + processFiles(files, { language }); + }; + + return ( +
+
+ + setLanguage(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s.languageHint}

+
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/extract-subtitles-settings.tsx b/apps/web/src/components/tools/extract-subtitles-settings.tsx new file mode 100644 index 00000000..be9f213e --- /dev/null +++ b/apps/web/src/components/tools/extract-subtitles-settings.tsx @@ -0,0 +1,52 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function ExtractSubtitlesSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["extract-subtitles"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("extract-subtitles"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+ {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/gif-to-video-settings.tsx b/apps/web/src/components/tools/gif-to-video-settings.tsx new file mode 100644 index 00000000..8d2bbd86 --- /dev/null +++ b/apps/web/src/components/tools/gif-to-video-settings.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Format = "mp4" | "webm"; + +export function GifToVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["gif-to-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("gif-to-video"); + + const [fmt, setFmt] = useState("mp4"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { format: fmt }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/images-to-video-settings.tsx b/apps/web/src/components/tools/images-to-video-settings.tsx new file mode 100644 index 00000000..78ddd518 --- /dev/null +++ b/apps/web/src/components/tools/images-to-video-settings.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +type Resolution = "1080p" | "720p" | "square"; + +export function ImagesToVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["images-to-video"]; + const { files } = useFileStore(); + const { processFiles, processing, error, progress } = useToolProcessor("images-to-video"); + + const [secondsPerImage, setSecondsPerImage] = useState(2); + const [resolution, setResolution] = useState("720p"); + const [fps, setFps] = useState(30); + + const hasEnough = files.length >= 2; + + const handleProcess = () => { + processFiles(files, { secondsPerImage, resolution, fps }); + }; + + return ( +
+
+ + setSecondsPerImage(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + +
+ +
+ + setFps(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/merge-videos-settings.tsx b/apps/web/src/components/tools/merge-videos-settings.tsx new file mode 100644 index 00000000..408bc091 --- /dev/null +++ b/apps/web/src/components/tools/merge-videos-settings.tsx @@ -0,0 +1,46 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +export function MergeVideosSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["merge-videos"]; + const { files } = useFileStore(); + const { processFiles, processing, error, progress } = useToolProcessor("merge-videos"); + + const hasEnough = files.length >= 2; + + const handleProcess = () => { + processFiles(files, {}); + }; + + return ( +
+

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/replace-audio-settings.tsx b/apps/web/src/components/tools/replace-audio-settings.tsx new file mode 100644 index 00000000..51b9a84e --- /dev/null +++ b/apps/web/src/components/tools/replace-audio-settings.tsx @@ -0,0 +1,46 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +export function ReplaceAudioSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["replace-audio"]; + const { files } = useFileStore(); + const { processFiles, processing, error, progress } = useToolProcessor("replace-audio"); + + const hasEnough = files.length >= 2; + + const handleProcess = () => { + processFiles(files, {}); + }; + + return ( +
+

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/resize-video-settings.tsx b/apps/web/src/components/tools/resize-video-settings.tsx new file mode 100644 index 00000000..66683260 --- /dev/null +++ b/apps/web/src/components/tools/resize-video-settings.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Preset = "custom" | "2160p" | "1440p" | "1080p" | "720p" | "480p" | "360p"; + +export function ResizeVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["resize-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("resize-video"); + + const [preset, setPreset] = useState("custom"); + const [width, setWidth] = useState(""); + const [height, setHeight] = useState(""); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + const isCustom = preset === "custom"; + const customValid = !isCustom || width !== "" || height !== ""; + + const handleProcess = () => { + const settings: Record = { preset }; + if (isCustom) { + if (width !== "") settings.width = width; + if (height !== "") settings.height = height; + } + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {isCustom && ( + <> +
+ + setWidth(e.target.value === "" ? "" : Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setHeight(e.target.value === "" ? "" : Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +

{s.hint}

+ + )} + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/reverse-video-settings.tsx b/apps/web/src/components/tools/reverse-video-settings.tsx new file mode 100644 index 00000000..1efa98d4 --- /dev/null +++ b/apps/web/src/components/tools/reverse-video-settings.tsx @@ -0,0 +1,54 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function ReverseVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["reverse-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("reverse-video"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/rotate-video-settings.tsx b/apps/web/src/components/tools/rotate-video-settings.tsx new file mode 100644 index 00000000..6079aa47 --- /dev/null +++ b/apps/web/src/components/tools/rotate-video-settings.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Transform = "cw90" | "ccw90" | "180" | "hflip" | "vflip"; + +export function RotateVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["rotate-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("rotate-video"); + + const [transform, setTransform] = useState("cw90"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { transform }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/stabilize-video-settings.tsx b/apps/web/src/components/tools/stabilize-video-settings.tsx new file mode 100644 index 00000000..4477afb8 --- /dev/null +++ b/apps/web/src/components/tools/stabilize-video-settings.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function StabilizeVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["stabilize-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("stabilize-video"); + + const [smoothing, setSmoothing] = useState(15); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { smoothing }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setSmoothing(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/video-color-settings.tsx b/apps/web/src/components/tools/video-color-settings.tsx new file mode 100644 index 00000000..8fba258d --- /dev/null +++ b/apps/web/src/components/tools/video-color-settings.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function VideoColorSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["video-color"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("video-color"); + + const [brightness, setBrightness] = useState(0); + const [contrast, setContrast] = useState(1); + const [saturation, setSaturation] = useState(1); + const [gamma, setGamma] = useState(1); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { brightness, contrast, saturation, gamma }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setBrightness(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setContrast(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setSaturation(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setGamma(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/video-loudnorm-settings.tsx b/apps/web/src/components/tools/video-loudnorm-settings.tsx new file mode 100644 index 00000000..a50eef48 --- /dev/null +++ b/apps/web/src/components/tools/video-loudnorm-settings.tsx @@ -0,0 +1,54 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function VideoLoudnormSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["video-loudnorm"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("video-loudnorm"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/video-metadata-settings.tsx b/apps/web/src/components/tools/video-metadata-settings.tsx new file mode 100644 index 00000000..6ad3f659 --- /dev/null +++ b/apps/web/src/components/tools/video-metadata-settings.tsx @@ -0,0 +1,54 @@ +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function VideoMetadataSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["video-metadata"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("video-metadata"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = {}; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+

{s.hint}

+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/video-speed-settings.tsx b/apps/web/src/components/tools/video-speed-settings.tsx new file mode 100644 index 00000000..efb71c55 --- /dev/null +++ b/apps/web/src/components/tools/video-speed-settings.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function VideoSpeedSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["video-speed"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("video-speed"); + + const [factor, setFactor] = useState(2); + const [keepPitch, setKeepPitch] = useState(true); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { factor, keepPitch }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setFactor(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/video-to-frames-settings.tsx b/apps/web/src/components/tools/video-to-frames-settings.tsx new file mode 100644 index 00000000..47edd03a --- /dev/null +++ b/apps/web/src/components/tools/video-to-frames-settings.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Mode = "all" | "nth" | "timestamps"; +type ImgFormat = "png" | "jpg"; + +export function VideoToFramesSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["video-to-frames"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("video-to-frames"); + + const [mode, setMode] = useState("all"); + const [n, setN] = useState(10); + const [timestamps, setTimestamps] = useState(""); + const [imgFormat, setImgFormat] = useState("png"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings: Record = { mode, format: imgFormat }; + if (mode === "nth") settings.n = n; + if (mode === "timestamps") settings.timestamps = timestamps; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + +
+ + {mode === "nth" && ( +
+ + setN(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ )} + + {mode === "timestamps" && ( +
+ + setTimestamps(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s.timestampsHint}

+
+ )} + +
+ + +
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/video-to-webp-settings.tsx b/apps/web/src/components/tools/video-to-webp-settings.tsx new file mode 100644 index 00000000..6bd88d65 --- /dev/null +++ b/apps/web/src/components/tools/video-to-webp-settings.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +export function VideoToWebpSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["video-to-webp"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("video-to-webp"); + + const [fps, setFps] = useState(12); + const [width, setWidth] = useState(480); + const [quality, setQuality] = useState(75); + const [loop, setLoop] = useState(true); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { fps, width, quality, loop }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setFps(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setWidth(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setQuality(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ + + + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/watermark-video-settings.tsx b/apps/web/src/components/tools/watermark-video-settings.tsx new file mode 100644 index 00000000..dd4991ab --- /dev/null +++ b/apps/web/src/components/tools/watermark-video-settings.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; +import { useFileStore } from "@/stores/file-store"; + +type Position = "tl" | "tc" | "tr" | "l" | "c" | "r" | "bl" | "bc" | "br"; + +export function WatermarkVideoSettings() { + const { t } = useTranslation(); + const s = t.toolSettings["watermark-video"]; + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, progress } = + useToolProcessor("watermark-video"); + + const [text, setText] = useState(""); + const [position, setPosition] = useState("br"); + const [fontSize, setFontSize] = useState(36); + const [opacity, setOpacity] = useState(0.5); + const [color, setColor] = useState("#ffffff"); + + const hasFile = files.length > 0; + const hasMultiple = files.length > 1; + + const handleProcess = () => { + const settings = { text, position, fontSize, opacity, color }; + if (hasMultiple) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + return ( +
+
+ + setText(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + +
+ +
+ + setFontSize(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setOpacity(Number(e.target.value))} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setColor(e.target.value)} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +

{s.colorHint}

+
+ + {error &&

{error}

} + + {processing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/lib/icon-map.ts b/apps/web/src/lib/icon-map.ts index d65979cc..e372d69a 100644 --- a/apps/web/src/lib/icon-map.ts +++ b/apps/web/src/lib/icon-map.ts @@ -1,11 +1,13 @@ import type { LucideIcon } from "lucide-react"; import { + Anchor, AppWindow, Archive, AudioLines, BookImage, BookOpen, Braces, + Captions, CheckCircle2, Code, Columns2, @@ -19,6 +21,7 @@ import { Expand, Eye, EyeOff, + FastForward, FileArchive, FileAudio, FileCode, @@ -33,11 +36,15 @@ import { Film, Focus, Frame, + GalleryHorizontalEnd, + GalleryThumbnails, + Gauge, Globe, Grid3x3, Hash, Image, ImagePlus, + Images, Info, Laugh, Layers, @@ -47,12 +54,16 @@ import { Lock, LockOpen, Maximize2, + MessageSquareText, + MicVocal, Minimize2, Palette, PenLine, PenTool, Pipette, QrCode, + RectangleHorizontal, + Rewind, RotateCw, Scaling, ScanEye, @@ -74,6 +85,7 @@ import { Undo2, UserCheck, Video, + Volume2, VolumeX, Wand, Wrench, @@ -84,12 +96,14 @@ import { // Only the icons actually used by tool definitions in @snapotter/shared constants. // Avoids `import * as icons from "lucide-react"` which pulls the entire 1000+ icon library. export const ICON_MAP: Record = { + Anchor, AppWindow, Archive, AudioLines, BookImage, BookOpen, Braces, + Captions, CheckCircle2, Code, Columns2, @@ -103,6 +117,7 @@ export const ICON_MAP: Record = { Expand, Eye, EyeOff, + FastForward, FileArchive, FileAudio, FileCode, @@ -117,11 +132,15 @@ export const ICON_MAP: Record = { Film, Focus, Frame, + GalleryHorizontalEnd, + GalleryThumbnails, + Gauge, Globe, Grid3x3, Hash, Image, ImagePlus, + Images, Info, Laugh, Layers, @@ -130,12 +149,16 @@ export const ICON_MAP: Record = { Lock, LockOpen, Maximize2, + MessageSquareText, + MicVocal, Minimize2, Palette, PenLine, PenTool, Pipette, QrCode, + RectangleHorizontal, + Rewind, RotateCw, Scaling, Scissors, @@ -156,6 +179,7 @@ export const ICON_MAP: Record = { Undo2, UserCheck, Video, + Volume2, VolumeX, Wand, Wrench, diff --git a/apps/web/src/lib/tool-display-modes.ts b/apps/web/src/lib/tool-display-modes.ts index 11bf5320..3af8ad6e 100644 --- a/apps/web/src/lib/tool-display-modes.ts +++ b/apps/web/src/lib/tool-display-modes.ts @@ -97,6 +97,28 @@ export const TOOL_DISPLAY_MODES: Record = { "trim-video": "media-player", "mute-video": "media-player", "video-to-gif": "side-by-side", + "resize-video": "media-player", + "crop-video": "media-player", + "rotate-video": "media-player", + "change-fps": "media-player", + "video-color": "media-player", + "video-speed": "media-player", + "reverse-video": "media-player", + "video-loudnorm": "media-player", + "aspect-pad": "media-player", + "blur-pad": "media-player", + "watermark-video": "media-player", + "stabilize-video": "media-player", + "gif-to-video": "media-player", + "video-to-webp": "side-by-side", + "video-to-frames": "no-comparison", + "merge-videos": "media-player", + "replace-audio": "media-player", + "burn-subtitles": "media-player", + "embed-subtitles": "media-player", + "extract-subtitles": "no-comparison", + "images-to-video": "media-player", + "video-metadata": "no-comparison", // Audio tools "convert-audio": "media-player", @@ -142,4 +164,11 @@ export const TOOL_DISPLAY_MODES: Record = { * Tools whose selected files all post in ONE request as repeated "file" parts. * Consumed by use-tool-processor; backend routes declare maxInputs. */ -export const MULTI_FILE_TOOLS: ReadonlySet = new Set(["merge-pdf"]); +export const MULTI_FILE_TOOLS: ReadonlySet = new Set([ + "merge-pdf", + "merge-videos", + "replace-audio", + "burn-subtitles", + "embed-subtitles", + "images-to-video", +]); diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 4f9497f7..1c2b058c 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -5,7 +5,7 @@ * Adding a new tool means adding one entry here instead of editing a 750-line file. */ -import { AUDIO_INPUTS, VIDEO_INPUTS } from "@snapotter/shared"; +import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "@snapotter/shared"; import type React from "react"; import { lazy } from "react"; import type { Crop } from "react-image-crop"; @@ -369,6 +369,116 @@ const VideoToGifSettings = lazy(() => default: m.VideoToGifSettings, })), ); +const ResizeVideoSettings = lazy(() => + import("@/components/tools/resize-video-settings").then((m) => ({ + default: m.ResizeVideoSettings, + })), +); +const CropVideoSettings = lazy(() => + import("@/components/tools/crop-video-settings").then((m) => ({ + default: m.CropVideoSettings, + })), +); +const RotateVideoSettings = lazy(() => + import("@/components/tools/rotate-video-settings").then((m) => ({ + default: m.RotateVideoSettings, + })), +); +const ChangeFpsSettings = lazy(() => + import("@/components/tools/change-fps-settings").then((m) => ({ + default: m.ChangeFpsSettings, + })), +); +const VideoColorSettings = lazy(() => + import("@/components/tools/video-color-settings").then((m) => ({ + default: m.VideoColorSettings, + })), +); +const VideoSpeedSettings = lazy(() => + import("@/components/tools/video-speed-settings").then((m) => ({ + default: m.VideoSpeedSettings, + })), +); +const ReverseVideoSettings = lazy(() => + import("@/components/tools/reverse-video-settings").then((m) => ({ + default: m.ReverseVideoSettings, + })), +); +const VideoLoudnormSettings = lazy(() => + import("@/components/tools/video-loudnorm-settings").then((m) => ({ + default: m.VideoLoudnormSettings, + })), +); +const AspectPadSettings = lazy(() => + import("@/components/tools/aspect-pad-settings").then((m) => ({ + default: m.AspectPadSettings, + })), +); +const BlurPadSettings = lazy(() => + import("@/components/tools/blur-pad-settings").then((m) => ({ + default: m.BlurPadSettings, + })), +); +const WatermarkVideoSettings = lazy(() => + import("@/components/tools/watermark-video-settings").then((m) => ({ + default: m.WatermarkVideoSettings, + })), +); +const StabilizeVideoSettings = lazy(() => + import("@/components/tools/stabilize-video-settings").then((m) => ({ + default: m.StabilizeVideoSettings, + })), +); +const GifToVideoSettings = lazy(() => + import("@/components/tools/gif-to-video-settings").then((m) => ({ + default: m.GifToVideoSettings, + })), +); +const VideoToWebpSettings = lazy(() => + import("@/components/tools/video-to-webp-settings").then((m) => ({ + default: m.VideoToWebpSettings, + })), +); +const VideoToFramesSettings = lazy(() => + import("@/components/tools/video-to-frames-settings").then((m) => ({ + default: m.VideoToFramesSettings, + })), +); +const MergeVideosSettings = lazy(() => + import("@/components/tools/merge-videos-settings").then((m) => ({ + default: m.MergeVideosSettings, + })), +); +const ReplaceAudioSettings = lazy(() => + import("@/components/tools/replace-audio-settings").then((m) => ({ + default: m.ReplaceAudioSettings, + })), +); +const BurnSubtitlesSettings = lazy(() => + import("@/components/tools/burn-subtitles-settings").then((m) => ({ + default: m.BurnSubtitlesSettings, + })), +); +const EmbedSubtitlesSettings = lazy(() => + import("@/components/tools/embed-subtitles-settings").then((m) => ({ + default: m.EmbedSubtitlesSettings, + })), +); +const ExtractSubtitlesSettings = lazy(() => + import("@/components/tools/extract-subtitles-settings").then((m) => ({ + default: m.ExtractSubtitlesSettings, + })), +); +const ImagesToVideoSettings = lazy(() => + import("@/components/tools/images-to-video-settings").then((m) => ({ + default: m.ImagesToVideoSettings, + })), +); +const VideoMetadataSettings = lazy(() => + import("@/components/tools/video-metadata-settings").then((m) => ({ + default: m.VideoMetadataSettings, + })), +); const ConvertAudioSettings = lazy(() => import("@/components/tools/convert-audio-settings").then((m) => ({ default: m.ConvertAudioSettings, @@ -653,6 +763,46 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [ ["trim-video", { accept: VIDEO_INPUTS.join(","), Settings: TrimVideoSettings }], ["mute-video", { accept: VIDEO_INPUTS.join(","), Settings: MuteVideoSettings }], ["video-to-gif", { accept: VIDEO_INPUTS.join(","), Settings: VideoToGifSettings }], + ["resize-video", { accept: VIDEO_INPUTS.join(","), Settings: ResizeVideoSettings }], + ["crop-video", { accept: VIDEO_INPUTS.join(","), Settings: CropVideoSettings }], + ["rotate-video", { accept: VIDEO_INPUTS.join(","), Settings: RotateVideoSettings }], + ["change-fps", { accept: VIDEO_INPUTS.join(","), Settings: ChangeFpsSettings }], + ["video-color", { accept: VIDEO_INPUTS.join(","), Settings: VideoColorSettings }], + ["video-speed", { accept: VIDEO_INPUTS.join(","), Settings: VideoSpeedSettings }], + ["reverse-video", { accept: VIDEO_INPUTS.join(","), Settings: ReverseVideoSettings }], + ["video-loudnorm", { accept: VIDEO_INPUTS.join(","), Settings: VideoLoudnormSettings }], + ["aspect-pad", { accept: VIDEO_INPUTS.join(","), Settings: AspectPadSettings }], + ["blur-pad", { accept: VIDEO_INPUTS.join(","), Settings: BlurPadSettings }], + ["watermark-video", { accept: VIDEO_INPUTS.join(","), Settings: WatermarkVideoSettings }], + ["stabilize-video", { accept: VIDEO_INPUTS.join(","), Settings: StabilizeVideoSettings }], + ["gif-to-video", { accept: ".gif", Settings: GifToVideoSettings }], + ["video-to-webp", { accept: VIDEO_INPUTS.join(","), Settings: VideoToWebpSettings }], + ["video-to-frames", { accept: VIDEO_INPUTS.join(","), Settings: VideoToFramesSettings }], + ["merge-videos", { accept: VIDEO_INPUTS.join(","), Settings: MergeVideosSettings }], + [ + "replace-audio", + { + accept: `${VIDEO_INPUTS.join(",")},${AUDIO_INPUTS.join(",")}`, + Settings: ReplaceAudioSettings, + }, + ], + [ + "burn-subtitles", + { + accept: `${VIDEO_INPUTS.join(",")},${SUBTITLE_INPUTS.join(",")}`, + Settings: BurnSubtitlesSettings, + }, + ], + [ + "embed-subtitles", + { + accept: `${VIDEO_INPUTS.join(",")},${SUBTITLE_INPUTS.join(",")}`, + Settings: EmbedSubtitlesSettings, + }, + ], + ["extract-subtitles", { accept: VIDEO_INPUTS.join(","), Settings: ExtractSubtitlesSettings }], + ["images-to-video", { accept: IMAGE_INPUTS.join(","), Settings: ImagesToVideoSettings }], + ["video-metadata", { accept: VIDEO_INPUTS.join(","), Settings: VideoMetadataSettings }], // Audio tools ["convert-audio", { accept: AUDIO_INPUTS.join(","), Settings: ConvertAudioSettings }], diff --git a/packages/media-engine/src/ffprobe.ts b/packages/media-engine/src/ffprobe.ts index b07d14ee..b550ad40 100644 --- a/packages/media-engine/src/ffprobe.ts +++ b/packages/media-engine/src/ffprobe.ts @@ -6,6 +6,7 @@ export interface MediaStreamInfo { codec: string; width?: number; height?: number; + sampleRate?: number; } export interface MediaInfo { @@ -70,7 +71,13 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro }); const parsed = JSON.parse(stdout) as { format?: { format_name?: string; duration?: string; bit_rate?: string }; - streams?: Array<{ codec_type?: string; codec_name?: string; width?: number; height?: number }>; + streams?: Array<{ + codec_type?: string; + codec_name?: string; + width?: number; + height?: number; + sample_rate?: string; + }>; }; const duration = parsed.format?.duration ? Number(parsed.format.duration) : null; const bitRate = parsed.format?.bit_rate ? Number(parsed.format.bit_rate) : null; @@ -78,11 +85,15 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro container: parsed.format?.format_name ?? "unknown", durationS: Number.isFinite(duration as number) ? (duration as number) : null, bitrateKbps: Number.isFinite(bitRate as number) ? Math.round((bitRate as number) / 1000) : null, - streams: (parsed.streams ?? []).map((s) => ({ - type: s.codec_type === "video" ? "video" : s.codec_type === "audio" ? "audio" : "other", - codec: s.codec_name ?? "unknown", - width: s.width, - height: s.height, - })), + streams: (parsed.streams ?? []).map((s) => { + const sr = s.sample_rate ? Number(s.sample_rate) : undefined; + return { + type: s.codec_type === "video" ? "video" : s.codec_type === "audio" ? "audio" : "other", + codec: s.codec_name ?? "unknown", + width: s.width, + height: s.height, + sampleRate: Number.isFinite(sr) && (sr as number) > 0 ? sr : undefined, + } as MediaStreamInfo; + }), }; } diff --git a/packages/media-engine/src/fonts.ts b/packages/media-engine/src/fonts.ts new file mode 100644 index 00000000..bb11102f --- /dev/null +++ b/packages/media-engine/src/fonts.ts @@ -0,0 +1,31 @@ +import { existsSync } from "node:fs"; + +export interface FontRef { + file: string; + family: string; +} + +const CANDIDATES: FontRef[] = [ + // Debian/Ubuntu container (fonts-dejavu-core, present since wave 2) + { file: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", family: "DejaVu Sans" }, + // macOS development hosts + { file: "/System/Library/Fonts/Supplemental/Arial.ttf", family: "Arial" }, + { file: "/System/Library/Fonts/Helvetica.ttc", family: "Helvetica" }, +]; + +/** + * Static ffmpeg builds carry no fontconfig database, so drawtext/libass + * filters need an explicit font file. Env overrides first, then known + * platform paths. Returns null when nothing exists (callers raise a clear + * tool error instead of a cryptic ffmpeg one). + */ +export function resolveFontFile(): FontRef | null { + const file = process.env.SNAPOTTER_FONT_FILE; + if (file) { + return { file, family: process.env.SNAPOTTER_FONT_FAMILY ?? "Sans" }; + } + for (const c of CANDIDATES) { + if (existsSync(c.file)) return c; + } + return null; +} diff --git a/packages/media-engine/src/index.ts b/packages/media-engine/src/index.ts index 58d06b28..81c80d90 100644 --- a/packages/media-engine/src/index.ts +++ b/packages/media-engine/src/index.ts @@ -2,4 +2,5 @@ export { ffmpegAvailable, resolveFfmpeg, resolveFfprobe } from "./binaries.js"; export { type EncoderTarget, resolveEncoder } from "./encoders.js"; export { type RunFfmpegOptions, runFfmpeg } from "./ffmpeg.js"; export { type MediaInfo, type MediaStreamInfo, type ProbeOptions, probeMedia } from "./ffprobe.js"; +export { type FontRef, resolveFontFile } from "./fonts.js"; export { type FfmpegProgress, parseProgressBlock } from "./progress.js"; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index acef0963..728dea74 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,4 +1,4 @@ -import { AUDIO_INPUTS, IMAGE_INPUTS, VIDEO_INPUTS } from "./modality.js"; +import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "./modality.js"; import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js"; export const CATEGORIES: CategoryInfo[] = [ @@ -666,6 +666,248 @@ export const TOOLS: Tool[] = [ acceptedInputs: VIDEO_INPUTS, executionHint: "long", }, + { + id: "resize-video", + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + category: "video", + icon: "Scaling", + route: "/resize-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "crop-video", + name: "Crop Video", + description: "Crop a region out of a video", + category: "video", + icon: "Crop", + route: "/crop-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "rotate-video", + name: "Rotate Video", + description: "Rotate or flip a video", + category: "video", + icon: "RotateCw", + route: "/rotate-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "change-fps", + name: "Change FPS", + description: "Change the frame rate of a video", + category: "video", + icon: "Gauge", + route: "/change-fps", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "video-color", + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + category: "video", + icon: "SlidersHorizontal", + route: "/video-color", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "video-speed", + name: "Video Speed", + description: "Speed up or slow down a video", + category: "video", + icon: "FastForward", + route: "/video-speed", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "reverse-video", + name: "Reverse Video", + description: "Play a video clip backwards", + category: "video", + icon: "Rewind", + route: "/reverse-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "video-loudnorm", + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + category: "video", + icon: "Volume2", + route: "/video-loudnorm", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "aspect-pad", + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + category: "video", + icon: "RectangleHorizontal", + route: "/aspect-pad", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "blur-pad", + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + category: "video", + icon: "GalleryHorizontalEnd", + route: "/blur-pad", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "watermark-video", + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + category: "video", + icon: "Stamp", + route: "/watermark-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "stabilize-video", + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + category: "video", + icon: "Anchor", + route: "/stabilize-video", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "long", + }, + { + id: "gif-to-video", + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + category: "video", + icon: "Film", + route: "/gif-to-video", + modality: "video", + acceptedInputs: [".gif"], + executionHint: "fast", + }, + { + id: "video-to-webp", + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + category: "video", + icon: "Image", + route: "/video-to-webp", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "video-to-frames", + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + category: "video", + icon: "Images", + route: "/video-to-frames", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "merge-videos", + name: "Merge Videos", + description: "Join multiple video clips into one file", + category: "video", + icon: "Combine", + route: "/merge-videos", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "long", + }, + { + id: "replace-audio", + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + category: "video", + icon: "MicVocal", + route: "/replace-audio", + modality: "video", + acceptedInputs: [...VIDEO_INPUTS, ...AUDIO_INPUTS], + executionHint: "fast", + }, + { + id: "burn-subtitles", + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + category: "video", + icon: "Captions", + route: "/burn-subtitles", + modality: "video", + acceptedInputs: [...VIDEO_INPUTS, ...SUBTITLE_INPUTS], + executionHint: "long", + }, + { + id: "embed-subtitles", + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + category: "video", + icon: "MessageSquareText", + route: "/embed-subtitles", + modality: "video", + acceptedInputs: [...VIDEO_INPUTS, ...SUBTITLE_INPUTS], + executionHint: "fast", + }, + { + id: "extract-subtitles", + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + category: "video", + icon: "FileText", + route: "/extract-subtitles", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, + { + id: "images-to-video", + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + category: "video", + icon: "GalleryThumbnails", + route: "/images-to-video", + modality: "video", + acceptedInputs: IMAGE_INPUTS, + executionHint: "fast", + }, + { + id: "video-metadata", + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + category: "video", + icon: "Tags", + route: "/video-metadata", + modality: "video", + acceptedInputs: VIDEO_INPUTS, + executionHint: "fast", + }, // Audio { id: "convert-audio", diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 683a38a7..55a745bd 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -296,6 +296,94 @@ export const ar: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1431,6 +1519,175 @@ export const ar: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 10d3a0dc..23e1accc 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -301,6 +301,94 @@ export const de: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1444,6 +1532,175 @@ export const de: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 16f3b6d5..2535e7fc 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -256,6 +256,94 @@ export const en = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1389,6 +1477,175 @@ export const en = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 758f85fc..85762b90 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -286,6 +286,94 @@ export const es: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1426,6 +1514,175 @@ export const es: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 1ac84f43..496c98d1 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -302,6 +302,94 @@ export const fr: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1445,6 +1533,175 @@ export const fr: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 08ef8301..65fc4bf6 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -293,6 +293,94 @@ export const hi: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1427,6 +1515,175 @@ export const hi: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 66bc1ef9..0a8f4ba6 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -301,6 +301,94 @@ export const id: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1439,6 +1527,175 @@ export const id: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 99739d0b..3bdf73a4 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -300,6 +300,94 @@ export const it: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1438,6 +1526,175 @@ export const it: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 8bc86490..9eeb0706 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -264,6 +264,94 @@ export const ja: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1397,6 +1485,175 @@ export const ja: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index b8ec0f77..63292c51 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -251,6 +251,94 @@ export const ko: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1382,6 +1470,175 @@ export const ko: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index ac50dda3..43676422 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -301,6 +301,94 @@ export const nl: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1441,6 +1529,175 @@ export const nl: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index add1a359..817c03ac 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -302,6 +302,94 @@ export const pl: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1442,6 +1530,175 @@ export const pl: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 2a5fe6ee..69e71041 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -299,6 +299,94 @@ export const ptBR: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1438,6 +1526,175 @@ export const ptBR: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index c9889258..4da27a4d 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -301,6 +301,94 @@ export const ru: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1440,6 +1528,175 @@ export const ru: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index a6755dc5..a6e5d462 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -299,6 +299,94 @@ export const sv: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1437,6 +1525,175 @@ export const sv: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 086dd42f..1568e3fc 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -294,6 +294,94 @@ export const th: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1420,6 +1508,175 @@ export const th: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 924ecd2d..0e59dddb 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -302,6 +302,94 @@ export const tr: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1442,6 +1530,175 @@ export const tr: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index b732f829..498594c4 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -301,6 +301,94 @@ export const uk: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1440,6 +1528,175 @@ export const uk: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 6d2261d1..02ab12b7 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -302,6 +302,94 @@ export const vi: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1439,6 +1527,175 @@ export const vi: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index d26b326b..03c08481 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -251,6 +251,94 @@ export const zhCN: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1374,6 +1462,175 @@ export const zhCN: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index caeacaeb..82d94e62 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -250,6 +250,94 @@ export const zhTW: TranslationKeys = { name: "Video to GIF", description: "Turn a video clip into an animated GIF", }, + "resize-video": { + name: "Resize Video", + description: "Scale a video to a new resolution or preset size", + }, + "crop-video": { + name: "Crop Video", + description: "Crop a region out of a video", + }, + "rotate-video": { + name: "Rotate Video", + description: "Rotate or flip a video", + }, + "change-fps": { + name: "Change FPS", + description: "Change the frame rate of a video", + }, + "video-color": { + name: "Video Color", + description: "Adjust brightness, contrast, saturation, and gamma", + }, + "video-speed": { + name: "Video Speed", + description: "Speed up or slow down a video", + }, + "reverse-video": { + name: "Reverse Video", + description: "Play a video clip backwards", + }, + "video-loudnorm": { + name: "Normalize Audio", + description: "Normalize video audio volume to broadcast standard", + }, + "aspect-pad": { + name: "Aspect Pad", + description: "Add solid-color bars to fit a target aspect ratio", + }, + "blur-pad": { + name: "Blur Pad", + description: "Fill bars with a blurred copy of the video", + }, + "watermark-video": { + name: "Watermark Video", + description: "Burn a text watermark onto video frames", + }, + "stabilize-video": { + name: "Stabilize Video", + description: "Reduce camera shake with two-pass stabilization", + }, + "gif-to-video": { + name: "GIF to Video", + description: "Convert an animated GIF into an MP4 or WebM video", + }, + "video-to-webp": { + name: "Video to WebP", + description: "Convert a video clip into an animated WebP image", + }, + "video-to-frames": { + name: "Video to Frames", + description: "Extract frames from a video as a ZIP of images", + }, + "merge-videos": { + name: "Merge Videos", + description: "Join multiple video clips into one file", + }, + "replace-audio": { + name: "Replace Audio", + description: "Swap the audio track of a video with another file", + }, + "burn-subtitles": { + name: "Burn Subtitles", + description: "Permanently render subtitles onto video frames", + }, + "embed-subtitles": { + name: "Embed Subtitles", + description: "Mux a subtitle track into the video container", + }, + "extract-subtitles": { + name: "Extract Subtitles", + description: "Pull the subtitle track out of a video as an SRT file", + }, + "images-to-video": { + name: "Images to Video", + description: "Turn a set of images into a slideshow video", + }, + "video-metadata": { + name: "Clean Video Metadata", + description: "Strip metadata from a video and report what was found", + }, "convert-audio": { name: "Convert Audio", description: "Convert audio between MP3, WAV, OGG, and more", @@ -1372,6 +1460,175 @@ export const zhTW: TranslationKeys = { submitBatch: "Create GIFs ({count} files)", progressLabel: "Creating GIF", }, + "resize-video": { + preset: "Preset", + width: "Width (px)", + height: "Height (px)", + hint: "Leave one dimension empty to keep aspect ratio", + submit: "Resize", + submitBatch: "Resize ({count} files)", + progressLabel: "Resizing", + }, + "crop-video": { + width: "Width (px)", + height: "Height (px)", + x: "X offset", + y: "Y offset", + submit: "Crop", + submitBatch: "Crop ({count} files)", + progressLabel: "Cropping", + }, + "rotate-video": { + transform: "Transform", + cw90: "Rotate 90 clockwise", + ccw90: "Rotate 90 counter-clockwise", + rotate180: "Rotate 180", + hflip: "Flip horizontal", + vflip: "Flip vertical", + submit: "Rotate", + submitBatch: "Rotate ({count} files)", + progressLabel: "Rotating", + }, + "change-fps": { + fps: "Frames per second", + submit: "Change FPS", + submitBatch: "Change FPS ({count} files)", + progressLabel: "Changing FPS", + }, + "video-color": { + brightness: "Brightness", + contrast: "Contrast", + saturation: "Saturation", + gamma: "Gamma", + submit: "Apply", + submitBatch: "Apply ({count} files)", + progressLabel: "Adjusting color", + }, + "video-speed": { + factor: "Speed factor", + keepPitch: "Keep original pitch", + submit: "Change speed", + submitBatch: "Change speed ({count} files)", + progressLabel: "Changing speed", + }, + "reverse-video": { + hint: "Limited to clips up to 5 minutes", + submit: "Reverse", + submitBatch: "Reverse ({count} files)", + progressLabel: "Reversing", + }, + "video-loudnorm": { + hint: "Normalizes audio to EBU R128 broadcast standard", + submit: "Normalize", + submitBatch: "Normalize ({count} files)", + progressLabel: "Normalizing", + }, + "aspect-pad": { + target: "Target ratio", + color: "Background color", + colorHint: "Hex color, e.g. #000000", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "blur-pad": { + target: "Target ratio", + blur: "Blur strength", + submit: "Pad", + submitBatch: "Pad ({count} files)", + progressLabel: "Padding", + }, + "watermark-video": { + text: "Watermark text", + position: "Position", + fontSize: "Font size", + opacity: "Opacity", + color: "Color", + colorHint: "Hex color, e.g. #ffffff", + submit: "Watermark", + submitBatch: "Watermark ({count} files)", + progressLabel: "Watermarking", + }, + "stabilize-video": { + smoothing: "Smoothing", + hint: "Runs two passes and takes longer than most tools", + submit: "Stabilize", + submitBatch: "Stabilize ({count} files)", + progressLabel: "Stabilizing", + }, + "gif-to-video": { + format: "Output format", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-webp": { + fps: "Frames per second", + width: "Width (px)", + quality: "Quality", + loop: "Loop animation", + submit: "Convert", + submitBatch: "Convert ({count} files)", + progressLabel: "Converting", + }, + "video-to-frames": { + mode: "Extraction mode", + modeAll: "All frames", + modeNth: "Every Nth frame", + modeTimestamps: "Specific timestamps", + n: "Take every Nth frame", + timestamps: "Timestamps (seconds)", + timestampsHint: "Comma-separated, e.g. 0.5, 2, 4.5", + format: "Image format", + submit: "Extract Frames", + submitBatch: "Extract Frames ({count} files)", + progressLabel: "Extracting frames", + }, + "merge-videos": { + hint: "Files are joined in upload order", + submit: "Merge", + submitBatch: "Merge ({count} files)", + progressLabel: "Merging", + }, + "replace-audio": { + hint: "Upload the video first, then the audio", + submit: "Replace Audio", + submitBatch: "Replace Audio ({count} files)", + progressLabel: "Replacing audio", + }, + "burn-subtitles": { + fontSize: "Font size", + hint: "Upload a video and a subtitle file (.srt or .vtt)", + submit: "Burn Subtitles", + submitBatch: "Burn Subtitles ({count} files)", + progressLabel: "Burning subtitles", + }, + "embed-subtitles": { + language: "Language code", + languageHint: "Three-letter ISO 639-2 code, e.g. eng, spa, fra", + submit: "Embed Subtitles", + submitBatch: "Embed Subtitles ({count} files)", + progressLabel: "Embedding subtitles", + }, + "extract-subtitles": { + submit: "Extract Subtitles", + submitBatch: "Extract Subtitles ({count} files)", + progressLabel: "Extracting subtitles", + }, + "images-to-video": { + secondsPerImage: "Seconds per image", + resolution: "Resolution", + fps: "Frames per second", + submit: "Create Video", + submitBatch: "Create Video ({count} files)", + progressLabel: "Creating slideshow", + }, + "video-metadata": { + hint: "Metadata report appears in the result", + submit: "Strip Metadata", + submitBatch: "Strip Metadata ({count} files)", + progressLabel: "Stripping metadata", + }, "convert-audio": { format: "Output format", bitrate: "Bitrate (kbps)", diff --git a/packages/shared/src/modality.ts b/packages/shared/src/modality.ts index d7fbf87d..d7cfb148 100644 --- a/packages/shared/src/modality.ts +++ b/packages/shared/src/modality.ts @@ -97,6 +97,7 @@ export const AUDIO_INPUTS = [ ".amr", ".ac3", ]; +export const SUBTITLE_INPUTS = [".srt", ".vtt", ".ass"] as const; export const DOCUMENT_INPUTS = [ ".pdf", ".docx", diff --git a/tests/fixtures/media/tiny-subs.mkv b/tests/fixtures/media/tiny-subs.mkv new file mode 100644 index 0000000000000000000000000000000000000000..a4b45fec3603156b1994398dc18bcf19aeb526cf GIT binary patch literal 13050 zcmb_@c|4Tg+yA*|24f#fvZb+x#E^t6W2aR1Hf3ZlI~67F8DuY&5G72MN~8rXj5Q@u zluDUFs8qIOH@`FW`8?0}`MrLBJb(Pg%zfYI+~+>$I@|ktU)Ob$wqnxh=BKEL`7HcR zQE}$eRJ=JYImk0IJS5_2q9C@DA zCun?04OZ{H6EAE|-j1f*dmi=G)7IRqsZAp7GW#FNh^v`s2KLm;!R-=04gSoalnRn% zN;3J(+#j(x{V@}*PuRBU)%@*$q^9;#k%*S$pXwizyRP? zT!NT0_}BwL!VjK55b_ZM09e2|AYfAA5CU-iFNNFwmBg%b6e@ME+^>zlZMZ|Ljdfc;x@4Cu=Pp90^XYPY_Doba|8t z=;X^Z0MLF6_#d?a45h>+^Fz4K^wVnvn+}8z-xyP}$vUU?LP_QrcH+RzcaMI`Y$yv@ zJM?<`M1RFIR*=1`SK!;c=`m%Myc-8=Q|T&MRZhtLb)U3DaII7&!JJrQ#?TE)J zSPBHbzw3WiA?y9!Fe`D#f4{b=s>P|QXGKIrMZ_INtV9)36<`jFZUYzXYPj4n&fHvV z_Rf!1U!RP;x2?|Bct?D%_;Z{yfy50&uaIybqBgt?->6N}CvMdC(KhfkfH!P4VF4}9 zM&6)Hgu57)^!D+BH!MOzqXT_>BZ)dB(#8!saH?UZe`I8+p_bOMW5+a)au!*jXRu~S zxStlM6;1!hpg>qABs4N0Bsjv5=;e9P)61Ad4EOOh)+T!U91INcI&2IVjv&7HaJ$M<)spikwGs;-c0A@r)`h*(m5(7fv zpu(wxW3CG)D9o4y;IiUU^GQxH6rcbUC+LUuJ>qlv#R<>k5mPph+M?j0-{WtALxA8#OyUoA^;o$#FGJrmj#I46dbOTXzP@qSQ;Jgltn~} zZdM+w9q@lVNDWgY9jtAzH5wG4ApQxV$m%t`+;YGHy9n@F$|1IIj)znLjUTP&N|bmQ zPnY)GjtkF}l&(QJ+Cl(vYGTikfh7uv+yH|$gZniK;N4I>D4ip^=-OY+6n1^P1lI2B z0fh^vg=Z##JFnPbBd{-6!8}_|lEIT_Pr{Ubd(Eo@3ISe|2OzLo)@luXfCnfKsEyS( zdw~X4wVu9m7-}mFz#t4=fYWS*amRTqnTi)4EsA z%G6w8g_V|weQW61K;i5Q!DL3>+P0LVC=}0FQ;D z5s@4`yj=+hJivq%`d@`(3koDc0T~bwkc94Sw$&8ndHTz+&1Q%_1va2?RTc57rJp7F zZ>}3g%)|-1#?o(GPptNrpBYGSbnlTq=&to#y{Ll6BB}8mVBrL!@cVsoB!fS-{*-&d zX0QM;5Y7SPQ7(?ybVo4@lN#&#ZFb52uD^E4P}SRZ@u}j+#U(d#vRv*vwHUS0FVuSW z41X&r&*q4P;W5H#RE?!SiX8IO{1mq&1m}_hhyvhb8yCt;=1rEh+RPa1-FT#my(U|7 ziJP|BiLYVf`nGp~z%?pD0hLNXLKtwxGZ2a`U^sLFNeM}5AXi9E>EuI^(}e60E{C3En%wd7Wa3GtCJxUwI}x0KZ-l1M_+`OE~$aibSBQH(FGM zsoqeNDG)CrgXiVdz=&#PW*yi?(LNq>%PgRQ0SX1yz;!GE(3BYMJNCPzmgp|C+;IKi z0EjnW>?VU4fWvXCI?eMQAaA`wPfv9*_VLd?G+;c>VW^pC$s-#j%d{`ae=0{n zfc1%1(BKgvAPOYRo@kK=ckE(tH-x7WfCLT>42o%hV?##J!k2)gAt?zy^PSwu2M~8u zU&Xm|3I#S4RrhO&WaJ*7nA8BG|GEW~x5*Gb6X1?Rcqw4k93Yq~xVEkf$lU`xyEk<3 z-&kt4$BY09k?c znO116y1jU0xGy(zz^nihAHy}T<)HNb#-!dvQSN>=-&b&Hk7t8^RTGML9!>%{ar;Wv z-<(D&AW0H{2tW`N87aNRPnrNKo3@%O9B><~A9i(3K3E9Cqc1$8bGwQ!1gpxURenok zoV*i@PA;KARgCCoeR#G%%lW3-n{!_~r+>8O8y#XV^p5vR5ACX8-yfQvZmoGU#vYtq ziJ828|EA!|1KpG2lKh4Cm5Eo%-iD}U)H3|^1q6PILA_gJYy0;=+W06T;g+PN)Tc)C z9p-_`%mKd2PdhKw9>^;HY98Vn>~QYtUG6L;r3k!>)^v0Q7Zw)f#e>zrIoLCFXCzcr9Mu~c5ELLG+)yAKv}7K zw|#9@K5vfyyOr3-&84QBp8NK()#i@069QTES2VC6MYwfR6;9%l?B!qKBR4#WTV7<( zEPfo?AADrb*w~i2r?Jo9xaHpIFd8*XjecV^U2ueV)UbG@tS(0`=Ii_cq*KCf@FKag z*{yw(`JGVuGa86Q5k8%Bm<*11v+S{XcJK1fxi{^%x6I5|Tww+?#gqptw195a0Q2~A zc)LbbyV_mf$s8%yLlwKTx?OckE&42~d${K4uW29+MQ}RF+1dq}7NtHX*xmJ$E50?4 zp3E4`%rZ?^b!_?a#Rd2Hsmri-gyZl)#ew>E_xtznc09TtUY-qg>hA|@9&S3;J)6B- zwMyZ$SY$hcZka>{SpWcBoLL@Gv_eL_MUpNagE{u1ufD-rwM7#A>a)wC#XbZZ>qjDH zx;(ojLA|oxC*x&S+U5t$C(A6Jl0TaM5+e&g$X6h-;|GzT z+UB(rTlrMZH(%*7Q`^X;H+wG55qI^OOre3QASvK(-Lbg?MsD4}$nE@N{Uhk$gItLR zmv-x}6o&1y=ejpjn#p9>AuT~9tfg}7T>dyyEUvL@-ht-iZQ>%d)jp|({P}8|@ZA7F zGAT?}RxxW~tOg^C-nKrhwMVKH3hu~Fkfa?7YbQdn*t0vhkqI~ZX+eM?$hMEjuG6iV zO$G|`n4wDmqlcA=8DM~9?eGbTjL^1GZtzZF{7T~lltP07h1%T!WyqDrrhcI8wcF0% zjcM3GZ2Be^)BB;PhM=;f^YdMOO>$o*(9h+m0XT4;mkb6eCmDc<%N1gXoJU$H(v;sI zJ=e$$DFtx}7xQ@LgF$XbREwL6?d}eBA9J5jZVJGQ0YI$*cz*EQN19l2RwRu*0dP_% z7P(q!0VMEF{R+=B_dNu!U@;=VOF{LU$@i2B5q29@5D##|7*zVS28c5^N{q6XlR-gQD(4Dvs8A9Qj&lw}^%cB7E9Q=FSgH9IW+3M^A)SP4 zt3vB)eq3BAV_k9KW(fbC6<>|%pKXN zuf))*Hwrm1+l9!#8@Qojn_GL%O1(v0=-woU4rTiz6!C9A%nU@^C;PVbpXtXt2}u-R zzr=y}Mn_Mo%=ae0P z2>@~+5CpPHNwf)%>z|%^tgvNxxI=*gg+VPMMQub8F>$=K*jj*Db}%YSLXlWqVRb+0 zjKK%2maX;kS?8=--(9LEotjdKal3}xnt}@s7*$OU(*OnqL_VSn9`FNVQ$tImA zknexok6Iioz8^eozrW9WZ7PzHc&0kPx>@3mw$#Up*_bZ+I1Ln|h=5LJwoXBUMG@gk z!d_9KgcIzzD@%2=H+yQX=Pbm|ZPBUO40w5YGptXpCG0Qc>Hhj_!SUC^N!56F;pm1F zp$;(vDZ2wh_>c#BJ7w;DJ=pY#C!G0JFnJ&<5^J>oKZW_;OnOe>}NO-RMi%&6h6q>>pal><0?d)=FTCU<3!q*7zu_Qoo+ z)lCK;bRYCJ=X71ZcKut{rzQJJMF*GN!OSU-eJFi^23~OxA9mz)vPH3ozonts82hy8 zmF7ClZCcZ_y+2mA9QEmetb)Pl(it!IQ#^fWt=fE@hgR0~=|fdH+Juo>CFE7r{ZIkK zVO}(qplu*6zvn|k-N{2tzxxmQd;c`?g`){z6Ao9*y`Y|=t>Hh?`Fq*)_jJU~!M%@| zEpB(1_39UMTtj8DJ)%S8PwnOJ{F2}(?r5nX;wqr5pr96hDee6?)f~SV@l4f>vee^~ z@23Y9S9$Q|)k{|HMCmLVU~|-JGVH~l>43cuN^u=ujAD0aonF27Om#ndXYZQ#1{FS1 z#L?)_Q_>?@g?e^^(W3>NuV%U0=HL-y1htKM5vq#W*0WJu{)%l%8vd<@(xV+G=cMN7 z7G_jL1gajafmt3|tU_>ZUCi9}SUl78#;xG#mufS@A#=pn?5``wF-s~! zqrPT-`xV}?rSjfEzx79@zFj|Komjmzet9gBF5ctpp4)?B0^CG5bth$P+dnr;MPDak z23bOYoAKFxqHt}ZcPFU}sDZ(X+)#xo_Y;uiTjTJXMjQa&I}CsNBmt}gPnkvnf=S8+ z;vfb;Ul2XA+G(4s){27OZXlP4Lg;h=#wq}tf`*G+AXNgdHfxtg27;>H(e*>|o++Z! z8qlDVAETdt-R29i5CwPz6i_KR=Z&k>HiRs)Z~(U!1!O!=?!XP8yXNM(!q&`)m(nCa z&;$q$p45>E9t=dK9BzLyAg1#H-P1v4-l2~wfqGkWVUhUD?O((i(ny+*2CRjzGuBLg z^*D~bXw^_;+&3#AmgRT77qHfqy9w(@@f)YcE)=>&YK)L?B_c zLAh{nOpCnjntZ_EM`erDz%L28FLsQ#QhaCzeXq$3*hb3fb_ zK77YKd2!h|_V+y-p|>YSXQcqHkZT6{Afj=9L!&H2%`5;N4Fb~618u7qEX62KCcBjp zt_!d#5`c^K*&lG*YXJThfSJ&TrUo{_!2xIqLA478#1j&O38m5nuA4RFgmT~R79mVL zNtoSSQ4fuOfL;Vh5kLw_ju^5JDi*w+RI0WkG>_F8e+s-}?syB@83F}^0I!;svmka< zUhJe6SN9{7-cLg${_av;@-=hCZhWM|D!gO;IVN`Kc5m11)^#l{dY7l~gK=B;EBa+xo zAU}tc^QhVF1dFSLFZtJ2wy>{kK0Xk8`116O@rpo!oJoj6=1#-(?fCNwB0$2ci&-DL z%s#bn;McK|@h5xTOP*9d`|QO#wGz9e+j~;`z%IGE$~G65^^woQE`L^L(0|hqtG{r` z>B73Mmj3%1>K0$+&6v~9i-ky zZIk)UF0-qTt7nd{Woo!9HD?vJsTZP8-Hdj6u9j&3zRhke(0pxWeE#M1=KhcL9vTwy zSDESB(ybMG#F*?9wyD$1v4C~4hoMRG-szqyDk>@_W72X5l*s{G_{lt1;;Vi)L;2y{ z_UYQ!;qOB}*KWx7ooPL1=B#r0U3fE#K0!nN7yuCVQ_fqPy~6k1jvHdKwE|+oV!Zq7 znCYW^%G)0w*+o{#3o%&tN%_^|SQPqo9UiKBRJiirj#p}*BJn38J5Q5Ep4WIP1|L&SMOcC@VV&AlVOb;!seN`v$QR*(Je4k07GZ-J;?lz)W)6@H}YDa|&<()gXR{ce+o z)>N;E`&fxst(GhvnYYTOSRQ+MBA0%(`JF-0wf0?6Mjgafb;b4Shl;H7qUi5wNEgSa zkp-*I|5N=SK?Xot~%m~L_!$6b;&%3if{CHm%@4nOy$ zmD>x?+V5q3y#Q?OI*o;QycLQGqFPDMyn5s=*kJwR@Mi!0KQq_bn`JF3@ZWkCeOY7M zUAtM!&@#KB3dhC0T{(v~+*wCopdoAyN+%yRcc?Ho?@H0t{KWpsT#l{SJGjkley*Z= zEc4gzh3D6=ue;JCrfQdXi+u9|&)CeuvW=;;^F`-En}s`u?sTO!-IDdL^PQJ}A3Jq9 z|C{moM4w5%w9Kis6V44sbOygn9Vcw^Vrt&ItrLPP&2HatbiWvjZeB*k@B;{bMP_-Z zSdr1p(h%Xrj-h^T!O%i?cZ7kR*acB9P)%c62ymf}PK<%dkw=RCkV}z>^vgDK%yG43I%APSdX-+WD&Qko6j^i6P^tcZGoX1WN{O5RgDZ0~wed*98KQ z5ukW`B?toI4J3dkykfvZDK8|Br_goK#v&;Qd!QRf@Knfn>ULS~)BE0J zw2=W()B%j)A&M-*O;sa__dMQQ{(;chy)h)B-m;K&`+8!}i3ua=h@Lh?{9>%0EH3Qp zIR?0=k00t*hRjgNf|QZ~!L5lLE|p+^6kr5&1qMAAUwSOBoXI@5Z{pdnREy65U zBUG5p#j7S5_!R&nWM|b!xeKCKQ{N$YH6GzTqAjh>vKj*?4o*nwRGz)5{iLY*_j7mX zbT2Ad`_)(y`ShB*-E;*G@_&Vv9q`LBsF>jzPj)V_CtI6L`={6LBVyx+&WD$!r; zzUaCxv+zREnRvKZoqfDQg1uot{U878TN(!P!#_r~|9Q^lKcd*X6vd8vJMOOh_FDbj zf;(?q)hC?_u~;m*WSwwJXNSe`)O<{xhuTQH>(-H>P5ax{%V16}xrZ5BM^q(mb=+`1 z{eUBbK1Rd90LuzzTb@p^DiZNeFjNpq{B(>R;xRp!^K^Bq#V_`nh2?e%R7>mxtW(xIcUC?cVl# z1LVR>M;s?0nZj6dVCGL_Hf7GH%Qsi*dSecKc(!F~yf64;Z}N@QEQ9_ik-H0>!T9UK zM$?_foi7WH>dY2WeMc*9;mX(BwU8+<44=xF{&YUl2F(`9esw9g z^}wq~Tt1P`X2Z5CS65flM`;+y`~L9d(+M_od4J2j5@V)A^}m1Tl!t9htLU4UUHrj% zY+t)E(!}|?h@EJ{qpJa~7O~5V<0r=J7RQ}ks++d698c6ozW5fg)5$KoHSzE&VJSg| z(#bEpI#E`RDpzm4s=?RL%`>T(WB{=#*(|RRV$)PIhDEK#n#N41jyby|$xUd#P~hE3 zML8BPpPbB|5n1Su87!evE3e*do24>9I-amf-5VgGc$3}!Km=`N8kMxxLV!{X)xK*q z{rvI=U5lj?{d%e}E=mpsM1Zbx`2$WIV}^jak-Gzim~?qWRKXhUcMzMpqBwRXst-gF zY(X}bSDxUrYnA2|X8j9^Gm(J z+ad}%)@J_YVHSnDyPA`~U@7Cy9uOb6pz;l%fW4|f0?N%XPT|(2E$PF^2^QB?2?!o# z${ND*O4T$y(Tv|VCCnHjz>5xyx5vV`AOd}w7#f5o0(yT#%AgN2#P8`KNH??^Xu<`^ z7pf7=D55qS+j)O2wP~6jR6GbRQz71#tFWg_vSMj=i6}R>s;DOgNq10UxLEIsqu_|U zBt@%y1kV&`F6%GT_?rca2_q@)YXxpQRmG_(**T*kJgR0AXG!kf^Eua_Y7`*xifM9< zea{cnP4mUN|q`TZGZhynF0A11P)0sP-41< z>Mq!5Q@Vz|1j(S5IN)ywm`O5t{Er!`0dT9Km5%~h1OX7%s%oXz>eXyH)89S4(&r8bFV#*(P5=C{_(mL+60bXW*jCI?CPtMnRkf;R zwPaDO+&rr551BXza&E77h6e62UAqXoF~`Q_@+H}^@g?6@Zy z(Q4yfmv4RURoHFk*B0bnQdVyIO!b*bSr%(+_hF-Z(dy!#zt4P#m_KkQDLk%LRjpVS z8rYbczW}O4$v5|UjgO4)U0FZ5(tEroduR2~iMgk4a~+?YTtH@KZr863lvO(&Z0#6l z6}EQR-`>3{@s_%BqrXI5&*vV`^O438S=W%-kDBjTx-laW&hctNMsy|()AbLKLKLDC zzBC)IHefCtUuJ(g{Y?9eIo%`!aSwIdKf1xnREwRw{#>Cp&f8Nn?G9ZLZ*4zhwH9>6~%+RQJ;d z5tBO>p53@r9gb=%I2i9}ddb=s%{zJ}FD|G3GrRrbidLt=REi8T^K$Fid#+7)`+|2p zQAtuTE79+F?BG6K=@G3mc`fuJ2zZt9Uabf+%U4b9mH=iy&cz&hL^Y%Fh zI|dA^VOBV(c+NZspJXoIQ+*|5DQ@ioJHKwz&2rZj=8);mx0jbnw`!uPdqUZ3D|%rJ z41O-#W{%QZaUY zbRlpDWn?M(K>O==id}z!P#U`Ec1}VU-AAP;k2p5unF0W?P9-Sd^~yAAWa+<(Vcrt;+G{jz%bUOa5Qp+;1V?nmOYU z-pxO{ibcK)kQm@6m_Wfb!Z!VK2lPcjXfjU9Cd?r-gOTj|HGgB=G*7Kf*{0u(ly#Zh z9Sng`B_An19jO)3!MsXUH-49wpRUv2(XgvHrrN(g_pi-{68-GzGYj9w(+2Nvo0)mJ z{NQJw`qX*DJHI#{_n+yPbZsEYL5KTvH0z4VvGfa1h9qU`4+~_5AtTC<3ex$w&pH%0 zyVJX9SdBjv3VQR?Ey`SDR<2xGnL6yg-R@#%OUz2m_suyM6bcs{%|$;EvoVv0#2<2A z0xZrrQJ44B_^a)~eW3MYQ51!DQ^rLSc>@=Ig5FERLQ)1%I9q2A-CUtZZN>gTf86&&kP#++4k-{c>At>DOxBkLVb2fWew@$(aQ-x}9PWINf& zzKpPa+Lc-}#Z5<6Tx6lehlRZCZ}=p^22x8~P8_Jh zRHk{ILtN9!u9Ma?n}(-`IDgriUN2Vm%njpXN$SdzzlnwGVvo=7bMNZX9`d{POX1h+ zyuE$~=i=qI^}p}@wMtSuM4_bUHNvkDJII1DC~P<$H$Ind z>l{1vJHsF2UwwY~-PR%Fea6`vT<);#oHQ;Sf98{Rr}WljC>_2Cj-_#6?4(&9i(_Ik z${Xr8RZY-kDX-^dQ~Ws`jyh59mt^Dh6v#sgLq(pJnn}?p%wm0-d!0WLpEo&E8Eb^_9UhL4d>XQa$sBJZgFpdM zTOy2PaP0-shlo5tc3&zLJ;M0t@$gXPp|#iK^^MdNEfGv1-*PdkBQzJuC1yx!Zy1+> zuJJ!MB|Mw8`HGM(0i2}&Zb&gdp&(w6A0S){38}kvPAX(BPO^YKk8td6*}r5iip*&) z6$n936P7^|+$8q!tq&2SSduPJm>DivPj$RG&P*hKP%QZ{S;=`32>}x(2`oS)=W?ub zKsFLC(udtwf&MTujmk(j(Af&XU0ev~`7F~?F-t4#hekG&C`q{H{7@rGBsH>41*_>k~;4~UiLXkv_^{caV`D+83 zcHbJ0_y&AA9LsTr!+_SGO$Yte|M;ME3hIBAkh>EJ{ zMO@#)_#A~Oo11S35@KzV7pqv{&7U+9*v#WU|IJ;C~fb)Q}L znlDh#t$I9J%HoViCwwo->AbX~P_ji4@yiA06;spk)`wFAEdj>@WSz7=Sbh7#lay3_ zu&Jr3^AsR9925R)}sq|X&C 00:00:00,500 +SnapOtter subtitle one + +2 +00:00:00,500 --> 00:00:01,000 +Second line diff --git a/tests/fixtures/media/tiny.vtt b/tests/fixtures/media/tiny.vtt new file mode 100644 index 00000000..eb969bba --- /dev/null +++ b/tests/fixtures/media/tiny.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00.000 --> 00:00.500 +SnapOtter subtitle one + +00:00.500 --> 00:01.000 +Second line diff --git a/tests/helpers/tool-default-settings.ts b/tests/helpers/tool-default-settings.ts index a789cb9a..2bc8ca6d 100644 --- a/tests/helpers/tool-default-settings.ts +++ b/tests/helpers/tool-default-settings.ts @@ -25,6 +25,10 @@ export const TOOL_SETTINGS_OVERRIDES: Record = { "unlock-pdf": { password: "test123" }, "watermark-pdf": { text: "CONFIDENTIAL" }, "redact-pdf": { terms: ["test"] }, + "crop-video": { width: 32, height: 32 }, + "rotate-video": { transform: "cw90" }, + "resize-video": { preset: "720p" }, + "watermark-video": { text: "CONFIDENTIAL" }, }; export function defaultSettingsFor(toolId: string): unknown { diff --git a/tests/integration/aspect-pad.test.ts b/tests/integration/aspect-pad.test.ts new file mode 100644 index 00000000..dccb0679 --- /dev/null +++ b/tests/integration/aspect-pad.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { canvasFor } from "../../apps/api/src/routes/tools/aspect-pad.js"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; +let sourceW: number; +let sourceH: number; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); + + const tmpDir = mkdtempSync(join(tmpdir(), "apad-src-")); + const srcFile = join(tmpDir, "tiny.mp4"); + writeFileSync(srcFile, MP4); + const info = await probeMedia(srcFile); + const v = info.streams.find((s) => s.type === "video"); + sourceW = v?.width ?? 0; + sourceH = v?.height ?? 0; +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/aspect-pad", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("aspect-pad (requires ffmpeg)", () => { + it("pads to 1:1 producing a square output", async () => { + const res = await runTool({ target: "1:1" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "apad-test-")); + const probeFile = join(tmpDir, "padded.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + expect(v?.width).toBe(v?.height); + }, 60_000); + + it("pads to 9:16 with dims matching canvasFor output", async () => { + const res = await runTool({ target: "9:16" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "apad-test2-")); + const probeFile = join(tmpDir, "padded916.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + + const expected = canvasFor(sourceW, sourceH, 9, 16); + expect(v?.width).toBe(expected.cw); + expect(v?.height).toBe(expected.ch); + }, 60_000); + + it("rejects invalid color with 400", async () => { + const res = await runTool({ color: "red" }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/blur-pad.test.ts b/tests/integration/blur-pad.test.ts new file mode 100644 index 00000000..017ef0b4 --- /dev/null +++ b/tests/integration/blur-pad.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { canvasFor } from "../../apps/api/src/routes/tools/blur-pad.js"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; +let sourceW: number; +let sourceH: number; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); + + const tmpDir = mkdtempSync(join(tmpdir(), "bpad-src-")); + const srcFile = join(tmpDir, "tiny.mp4"); + writeFileSync(srcFile, MP4); + const info = await probeMedia(srcFile); + const v = info.streams.find((s) => s.type === "video"); + sourceW = v?.width ?? 0; + sourceH = v?.height ?? 0; +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/blur-pad", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("blur-pad (requires ffmpeg)", () => { + it("pads to 1:1 producing a square output", async () => { + const res = await runTool({ target: "1:1" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "bpad-test-")); + const probeFile = join(tmpDir, "blurpad.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + expect(v?.width).toBe(v?.height); + }, 60_000); + + it("pads to default 16:9 with dims matching canvasFor output", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "bpad-test2-")); + const probeFile = join(tmpDir, "blurpad169.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + + const expected = canvasFor(sourceW, sourceH, 16, 9); + expect(v?.width).toBe(expected.cw); + expect(v?.height).toBe(expected.ch); + }, 60_000); +}); diff --git a/tests/integration/burn-subtitles.test.ts b/tests/integration/burn-subtitles.test.ts new file mode 100644 index 00000000..1e3a6de5 --- /dev/null +++ b/tests/integration/burn-subtitles.test.ts @@ -0,0 +1,129 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); +const SRT = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.srt")); +const VTT = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.vtt")); +const PNG = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** + * Handles both the 200 sync-window path and the 202+poll path. + * burn-subtitles has executionHint "long", so 202 is expected in most runs. + */ +async function resolveResult(res: Awaited>): Promise<{ + path: "sync" | "poll"; + downloadPayload: Buffer; +}> { + if (res.statusCode === 200) { + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + return { path: "sync", downloadPayload: dl.rawPayload }; + } + + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + expect(jobId).toBeDefined(); + + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + return { path: "poll", downloadPayload: dl.rawPayload }; +} + +describe.skipIf(!ffmpegAvailable())("burn-subtitles (requires ffmpeg)", () => { + it("burns SRT subtitles onto a video", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/burn-subtitles", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + const { downloadPayload } = await resolveResult(res); + expect(downloadPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "burn-srt-")); + const probeFile = join(tmpDir, "burned.mp4"); + writeFileSync(probeFile, downloadPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + }, 120_000); + + it("burns VTT subtitles onto a video", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "file", filename: "tiny.vtt", contentType: "text/vtt", content: VTT }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/burn-subtitles", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + const { downloadPayload } = await resolveResult(res); + expect(downloadPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "burn-vtt-")); + const probeFile = join(tmpDir, "burned.mp4"); + writeFileSync(probeFile, downloadPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + }, 120_000); + + it("rejects a PNG as the second file (subtitle kind rejection)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "file", filename: "image.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/burn-subtitles", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + // The inputKinds seam validates input 1 as kind "subtitle". + // A PNG fails the subtitle kind validation. + expect(res.statusCode).toBe(400); + const parsed = JSON.parse(res.body); + expect(parsed.error).toMatch(/subtitle/i); + }, 60_000); +}); diff --git a/tests/integration/change-fps.test.ts b/tests/integration/change-fps.test.ts new file mode 100644 index 00000000..3860a2ba --- /dev/null +++ b/tests/integration/change-fps.test.ts @@ -0,0 +1,64 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/change-fps", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("change-fps (requires ffmpeg)", () => { + it("changes to 10 fps and verifies via ffprobe", async () => { + const res = await runTool({ fps: 10 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + // Write to tmp and probe frame rate directly via ffprobe spawnSync + const tmpDir = mkdtempSync(join(tmpdir(), "fps-test-")); + const probeFile = join(tmpDir, "fps.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const result = spawnSync("ffprobe", [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=r_frame_rate", + "-of", + "csv=p=0", + probeFile, + ]); + const fps = result.stdout.toString().trim(); + expect(fps).toBe("10/1"); + }, 60_000); +}); diff --git a/tests/integration/crop-video.test.ts b/tests/integration/crop-video.test.ts new file mode 100644 index 00000000..0e20e81b --- /dev/null +++ b/tests/integration/crop-video.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/crop-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("crop-video (requires ffmpeg)", () => { + it("crops to 32x32 and returns 200", async () => { + const res = await runTool({ width: 32, height: 32, x: 0, y: 0 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "crop-test-")); + const probeFile = join(tmpDir, "cropped.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + expect(v?.width).toBe(32); + expect(v?.height).toBe(32); + }, 60_000); + + it("rejects crop rect exceeding video dimensions with 422", async () => { + const res = await runTool({ width: 9999, height: 9999 }); + expect(res.statusCode).toBe(422); + const body = JSON.parse(res.body); + expect(body.details || body.error).toMatch(/exceeds video size/i); + }, 60_000); + + it("rejects missing width with 400", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/embed-subtitles.test.ts b/tests/integration/embed-subtitles.test.ts new file mode 100644 index 00000000..92a8815f --- /dev/null +++ b/tests/integration/embed-subtitles.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); +const SRT = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.srt")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("embed-subtitles (requires ffmpeg)", () => { + it("embeds an SRT subtitle track into an MP4", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/embed-subtitles", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + // Probe the output: should have a non-av stream (subtitle mapped as "other") + const tmpDir = mkdtempSync(join(tmpdir(), "embed-sub-")); + const probeFile = join(tmpDir, "embedded.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "other")).toBe(true); + }, 60_000); + + it("rejects invalid language code 'english' with 400", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT }, + { name: "settings", content: JSON.stringify({ language: "english" }) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/embed-subtitles", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }, 60_000); +}); diff --git a/tests/integration/extract-subtitles.test.ts b/tests/integration/extract-subtitles.test.ts new file mode 100644 index 00000000..ba2300bd --- /dev/null +++ b/tests/integration/extract-subtitles.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MKV = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny-subs.mkv")); +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("extract-subtitles (requires ffmpeg)", () => { + it("extracts the subtitle track from an MKV with embedded subs", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny-subs.mkv", contentType: "video/x-matroska", content: MKV }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/extract-subtitles", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const srtContent = dl.rawPayload.toString("utf8"); + expect(srtContent).toContain("SnapOtter subtitle one"); + }, 60_000); + + it("rejects a video with no subtitle track (422)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/extract-subtitles", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/no subtitle track/i); + }, 60_000); +}); diff --git a/tests/integration/gif-to-video.test.ts b/tests/integration/gif-to-video.test.ts new file mode 100644 index 00000000..d59a5be4 --- /dev/null +++ b/tests/integration/gif-to-video.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const GIF = readFileSync(join(__dirname, "..", "fixtures", "animated.gif")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "animated.gif", contentType: "image/gif", content: GIF }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/gif-to-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("gif-to-video (requires ffmpeg)", () => { + it("converts a GIF to mp4 by default and returns 200", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + // Probe the downloaded file to verify it has a video stream + const tmpDir = mkdtempSync(join(tmpdir(), "gif2vid-")); + const probeFile = join(tmpDir, "out.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + }, 60_000); + + it("converts a GIF to webm when format is webm", async () => { + const res = await runTool({ format: "webm" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "gif2webm-")); + const probeFile = join(tmpDir, "out.webm"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + }, 60_000); +}); diff --git a/tests/integration/images-to-video.test.ts b/tests/integration/images-to-video.test.ts new file mode 100644 index 00000000..8553310e --- /dev/null +++ b/tests/integration/images-to-video.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const JPG = readFileSync(join(__dirname, "..", "fixtures", "test-100x100.jpg")); +const PNG = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("images-to-video (requires ffmpeg)", () => { + it("creates a slideshow from two images with default settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG }, + { name: "file", filename: "photo.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/images-to-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "slideshow-")); + const probeFile = join(tmpDir, "slideshow.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + + // Duration should be roughly 4s (2 images x 2s default, 30% tolerance) + expect(info.durationS).not.toBeNull(); + const dur = info.durationS as number; + expect(dur).toBeGreaterThan(4 * 0.7); + expect(dur).toBeLessThan(4 * 1.3); + + // Resolution should be 1280x720 (720p default) + const v = info.streams.find((s) => s.type === "video"); + expect(v).toBeDefined(); + expect(v?.width).toBe(1280); + expect(v?.height).toBe(720); + }, 60_000); + + it("rejects a single image with 422", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/images-to-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + // fast hint: inline 422 + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/at least two images/i); + }, 60_000); +}); diff --git a/tests/integration/merge-videos.test.ts b/tests/integration/merge-videos.test.ts new file mode 100644 index 00000000..609858ec --- /dev/null +++ b/tests/integration/merge-videos.test.ts @@ -0,0 +1,137 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** + * Handles both the 200 sync-window path and the 202+poll path. + * merge-videos has executionHint "long", so expect 202 in most runs. + */ +async function resolveResult(res: Awaited>): Promise<{ + path: "sync" | "poll"; + downloadPayload: Buffer; +}> { + if (res.statusCode === 200) { + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + return { path: "sync", downloadPayload: dl.rawPayload }; + } + + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + expect(jobId).toBeDefined(); + + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + return { path: "poll", downloadPayload: dl.rawPayload }; +} + +describe.skipIf(!ffmpegAvailable())("merge-videos (requires ffmpeg)", () => { + it("merges two copies of tiny.mp4 into a single video", async () => { + // Send TWO file parts named "file" (the multi-input path) + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.mp4", contentType: "video/mp4", content: MP4 }, + { name: "file", filename: "b.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-videos", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + const { path, downloadPayload } = await resolveResult(res); + expect(downloadPayload.length).toBeGreaterThan(100); + + // Probe the merged output: duration should be roughly 2x the source + const tmpDir = mkdtempSync(join(tmpdir(), "merge-test-")); + const probeFile = join(tmpDir, "merged.mp4"); + writeFileSync(probeFile, downloadPayload); + const info = await probeMedia(probeFile); + + // Probe the source for comparison + const srcFile = join(tmpDir, "src.mp4"); + writeFileSync(srcFile, MP4); + const srcInfo = await probeMedia(srcFile); + const srcDuration = srcInfo.durationS ?? 0; + + expect(info.durationS).not.toBeNull(); + const merged = info.durationS as number; + // Within 30% tolerance of 2x source + expect(merged).toBeGreaterThan(srcDuration * 2 * 0.7); + expect(merged).toBeLessThan(srcDuration * 2 * 1.3); + + // Record which path was taken for the report + // eslint-disable-next-line no-console + console.log(`merge-videos happy path: ${path}`); + }, 120_000); + + it("rejects a single file", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "only.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/merge-videos", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + // merge-videos has executionHint "long", so 202 is returned immediately. + // The worker then fails with InputValidationError. Poll for the failure. + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + expect(jobId).toBeDefined(); + + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; error: unknown } | undefined; + for (let i = 0; i < 60; i++) { + [row] = await db + .select({ status: schema.jobs.status, error: schema.jobs.error }) + .from(schema.jobs) + .where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + + // Observed behavior: 202 (async) -> job status "failed" with error message. + // The plan noted "400/422" but long-hint tools skip the sync window. + expect(row?.status).toBe("failed"); + const error = row?.error as { message?: string } | null; + expect(error?.message).toMatch(/at least two/i); + }, 60_000); +}); diff --git a/tests/integration/modality-media-kinds.test.ts b/tests/integration/modality-media-kinds.test.ts new file mode 100644 index 00000000..c6023ec9 --- /dev/null +++ b/tests/integration/modality-media-kinds.test.ts @@ -0,0 +1,34 @@ +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { describe, expect, it } from "vitest"; +import { MediaInputHandler } from "../../apps/api/src/modality/media-input.js"; + +const FIX = join(__dirname, "..", "fixtures"); +const MP4 = readFileSync(join(FIX, "media", "tiny.mp4")); +const MP3 = readFileSync(join(FIX, "media", "tiny.mp3")); +const SRT = readFileSync(join(FIX, "media", "tiny.srt")); +const PNG = readFileSync(join(FIX, "test-200x150.png")); + +function scratch(): { scratchDir: string } { + return { scratchDir: mkdtempSync(join(tmpdir(), "media-kinds-")) }; +} + +describe.skipIf(!ffmpegAvailable())("media input kinds", () => { + it("kind subtitle accepts srt without ffprobe and rejects binaries", async () => { + const h = new MediaInputHandler("subtitle"); + await expect(h.prepare(SRT, "s.srt", scratch())).resolves.toBeDefined(); + await expect(h.prepare(MP4, "v.srt", scratch())).rejects.toThrow(/subtitle/i); + }); + it("kind image accepts a still image and rejects audio", async () => { + const h = new MediaInputHandler("image"); + await expect(h.prepare(PNG, "p.png", scratch())).resolves.toBeDefined(); + await expect(h.prepare(MP3, "a.mp3", scratch())).rejects.toThrow(/image/i); + }); + it("kind video still rejects audio-only and stills (regression)", async () => { + const h = new MediaInputHandler("video"); + await expect(h.prepare(MP3, "a.mp3", scratch())).rejects.toThrow(/video/i); + await expect(h.prepare(PNG, "p.png", scratch())).rejects.toThrow(/still image/i); + }); +}); diff --git a/tests/integration/replace-audio.test.ts b/tests/integration/replace-audio.test.ts new file mode 100644 index 00000000..6d819623 --- /dev/null +++ b/tests/integration/replace-audio.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); +const MP3 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp3")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("replace-audio (requires ffmpeg)", () => { + it("replaces video audio with the supplied audio track", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/replace-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + // replace-audio is fast-hint, so expect 200 (sync window) + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + // Probe the output: must have both video and audio streams + const tmpDir = mkdtempSync(join(tmpdir(), "replace-audio-test-")); + const probeFile = join(tmpDir, "replaced.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + expect(info.streams.some((s) => s.type === "audio")).toBe(true); + }, 60_000); + + it("rejects when files are in wrong order (mp3 first, mp4 second)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 }, + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/replace-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + // The inputKinds seam validates input 0 as kind "video". + // mp3 has no video stream, so the handler rejects before processV2. + // InputValidationError defaults to 400. + expect(res.statusCode).toBe(400); + const parsed = JSON.parse(res.body); + expect(parsed.error).toMatch(/no video stream/i); + }, 60_000); + + it("rejects when only one file is provided", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/replace-audio", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + // Fast-hint tool: processV2 checks input count and throws, + // caught by the factory's generic catch -> 422. + expect(res.statusCode).toBe(422); + const parsed = JSON.parse(res.body); + expect(parsed.details).toMatch(/video and an audio/i); + }, 60_000); +}); diff --git a/tests/integration/resize-video.test.ts b/tests/integration/resize-video.test.ts new file mode 100644 index 00000000..5e9816b0 --- /dev/null +++ b/tests/integration/resize-video.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/resize-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("resize-video (requires ffmpeg)", () => { + it("resizes to width 64 and returns 200", async () => { + const res = await runTool({ width: 64 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "resize-test-")); + const probeFile = join(tmpDir, "resized.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + expect(v?.width).toBe(64); + }, 60_000); + + it("rejects custom preset with no dimensions", async () => { + const res = await runTool({ preset: "custom" }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/reverse-video.test.ts b/tests/integration/reverse-video.test.ts new file mode 100644 index 00000000..2ca51e50 --- /dev/null +++ b/tests/integration/reverse-video.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; +let sourceDurationS: number; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); + + const tmpDir = mkdtempSync(join(tmpdir(), "rev-src-")); + const srcFile = join(tmpDir, "tiny.mp4"); + writeFileSync(srcFile, MP4); + const info = await probeMedia(srcFile); + sourceDurationS = info.durationS ?? 1; +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/reverse-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("reverse-video (requires ffmpeg)", () => { + it("reverses a clip and returns 200 with similar duration", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "rev-test-")); + const probeFile = join(tmpDir, "reversed.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.durationS).not.toBeNull(); + + const tolerance = sourceDurationS * 0.3; + expect(info.durationS as number).toBeGreaterThan(sourceDurationS - tolerance); + expect(info.durationS as number).toBeLessThan(sourceDurationS + tolerance); + }, 60_000); +}); diff --git a/tests/integration/rotate-video.test.ts b/tests/integration/rotate-video.test.ts new file mode 100644 index 00000000..60b03071 --- /dev/null +++ b/tests/integration/rotate-video.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/rotate-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("rotate-video (requires ffmpeg)", () => { + it("rotates cw90 and swaps dimensions", async () => { + // First probe the source to know its original dimensions + const srcDir = mkdtempSync(join(tmpdir(), "rotate-src-")); + const srcFile = join(srcDir, "tiny.mp4"); + writeFileSync(srcFile, MP4); + const srcInfo = await probeMedia(srcFile); + const srcV = srcInfo.streams.find((s) => s.type === "video"); + const srcW = srcV?.width ?? 0; + const srcH = srcV?.height ?? 0; + + const res = await runTool({ transform: "cw90" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "rotate-test-")); + const probeFile = join(tmpDir, "rotated.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + // After 90-degree rotation, width and height should swap + expect(v?.width).toBe(srcH); + expect(v?.height).toBe(srcW); + }, 60_000); +}); diff --git a/tests/integration/stabilize-video.test.ts b/tests/integration/stabilize-video.test.ts new file mode 100644 index 00000000..8ca0be3c --- /dev/null +++ b/tests/integration/stabilize-video.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record = {}) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/stabilize-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +/** + * Handles both the 200 sync-window path and the 202+poll path. + * Long-hint tools may complete within the sync window (200) or go async (202). + */ +async function resolveResult(res: Awaited>): Promise<{ + path: "sync" | "poll"; + downloadPayload: Buffer; +}> { + if (res.statusCode === 200) { + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + return { path: "sync", downloadPayload: dl.rawPayload }; + } + + expect(res.statusCode).toBe(202); + const { jobId } = JSON.parse(res.body); + expect(jobId).toBeDefined(); + + const { db, schema } = await import("../../apps/api/src/db/index.js"); + const { eq } = await import("drizzle-orm"); + let row: { status: string; outputRefs: unknown } | undefined; + for (let i = 0; i < 120; i++) { + [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId)); + if (row && ["completed", "failed", "canceled"].includes(row.status)) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(row?.status).toBe("completed"); + const outName = (row?.outputRefs as string[])[0].split("/").pop() as string; + const dl = await testApp.app.inject({ + method: "GET", + url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`, + }); + expect(dl.statusCode).toBe(200); + return { path: "poll", downloadPayload: dl.rawPayload }; +} + +describe.skipIf(!ffmpegAvailable())("stabilize-video (requires ffmpeg)", () => { + it("stabilizes a video and returns a downloadable mp4", async () => { + const res = await runTool({}); + const { downloadPayload } = await resolveResult(res); + expect(downloadPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "stab-test-")); + const probeFile = join(tmpDir, "stabilized.mp4"); + writeFileSync(probeFile, downloadPayload); + const info = await probeMedia(probeFile); + const v = info.streams.find((s) => s.type === "video"); + expect(v).toBeDefined(); + }, 120_000); + + it("rejects smoothing out of range with 400", async () => { + const res = await runTool({ smoothing: 100 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/video-color.test.ts b/tests/integration/video-color.test.ts new file mode 100644 index 00000000..14cf053c --- /dev/null +++ b/tests/integration/video-color.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-color", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("video-color (requires ffmpeg)", () => { + it("desaturates (saturation 0) and returns 200", async () => { + const res = await runTool({ saturation: 0 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 60_000); + + it("rejects brightness out of range with 400", async () => { + const res = await runTool({ brightness: 5 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/video-loudnorm.test.ts b/tests/integration/video-loudnorm.test.ts new file mode 100644 index 00000000..133f4d49 --- /dev/null +++ b/tests/integration/video-loudnorm.test.ts @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; +let silentMp4: Buffer; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); + + // Generate a silent (no audio track) clip for the rejection test + const tmpDir = mkdtempSync(join(tmpdir(), "loudnorm-fixture-")); + const silentPath = join(tmpDir, "silent.mp4"); + const result = spawnSync( + "ffmpeg", + ["-f", "lavfi", "-i", "color=red:s=64x64:d=1", "-an", "-y", silentPath], + { timeout: 15_000 }, + ); + if (result.status !== 0) { + throw new Error(`Failed to generate silent fixture: ${result.stderr?.toString()}`); + } + silentMp4 = readFileSync(silentPath); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("video-loudnorm (requires ffmpeg)", () => { + it("normalizes audio and returns 200", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-loudnorm", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + }, 60_000); + + it("rejects a video with no audio track with 422", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "silent.mp4", contentType: "video/mp4", content: silentMp4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-loudnorm", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + expect(res.statusCode).toBe(422); + const resBody = JSON.parse(res.body); + expect(resBody.details || resBody.error).toMatch(/no audio track/i); + }, 60_000); +}); diff --git a/tests/integration/video-metadata.test.ts b/tests/integration/video-metadata.test.ts new file mode 100644 index 00000000..ea6adea8 --- /dev/null +++ b/tests/integration/video-metadata.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe.skipIf(!ffmpegAvailable())("video-metadata (requires ffmpeg)", () => { + it("strips metadata and returns probe data in the envelope", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + // Sync resultPayload passthrough: metadata at top level of envelope + expect(envelope.metadata).toBeDefined(); + expect(Array.isArray(envelope.metadata.streams)).toBe(true); + expect(envelope.metadata.streams.length).toBeGreaterThan(0); + + // Download and probe the cleaned file + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "vid-meta-")); + const probeFile = join(tmpDir, "clean.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + }, 60_000); +}); diff --git a/tests/integration/video-speed.test.ts b/tests/integration/video-speed.test.ts new file mode 100644 index 00000000..21138d54 --- /dev/null +++ b/tests/integration/video-speed.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; +let sourceDurationS: number; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); + + // Probe source duration and verify sampleRate for comparison + const tmpDir = mkdtempSync(join(tmpdir(), "speed-src-")); + const srcFile = join(tmpDir, "tiny.mp4"); + writeFileSync(srcFile, MP4); + const info = await probeMedia(srcFile); + sourceDurationS = info.durationS ?? 1; + const audioStream = info.streams.find((s) => s.type === "audio"); + if (audioStream) { + // Verify sampleRate is populated correctly from ffprobe + expect(audioStream.sampleRate).toBe(44100); + } +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-speed", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("video-speed (requires ffmpeg)", () => { + it("speeds up by factor 2 and halves duration", async () => { + const res = await runTool({ factor: 2 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "speed-test-")); + const probeFile = join(tmpDir, "speed.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.durationS).not.toBeNull(); + + const expected = sourceDurationS / 2; + const tolerance = expected * 0.25; + expect(info.durationS as number).toBeGreaterThan(expected - tolerance); + expect(info.durationS as number).toBeLessThan(expected + tolerance); + }, 60_000); + + it("speeds up by factor 2 with keepPitch=false and halves duration", async () => { + const res = await runTool({ factor: 2, keepPitch: false }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "speed-nopitch-")); + const probeFile = join(tmpDir, "speed-nopitch.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.durationS).not.toBeNull(); + + const expected = sourceDurationS / 2; + const tolerance = expected * 0.25; + expect(info.durationS as number).toBeGreaterThan(expected - tolerance); + expect(info.durationS as number).toBeLessThan(expected + tolerance); + }, 60_000); + + it("rejects factor out of range with 400", async () => { + const res = await runTool({ factor: 8 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/video-to-frames.test.ts b/tests/integration/video-to-frames.test.ts new file mode 100644 index 00000000..fda34be7 --- /dev/null +++ b/tests/integration/video-to-frames.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-to-frames", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("video-to-frames (requires ffmpeg)", () => { + it("extracts every-Nth frame and returns a zip", async () => { + const res = await runTool({ mode: "nth", n: 2 }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + // Verify PK zip magic and reasonable size + const buf = dl.rawPayload; + expect(buf[0]).toBe(0x50); // P + expect(buf[1]).toBe(0x4b); // K + expect(buf[2]).toBe(0x03); + expect(buf[3]).toBe(0x04); + expect(buf.length).toBeGreaterThan(200); + }, 60_000); + + it("extracts a single frame at timestamp 0 and returns a zip", async () => { + const res = await runTool({ mode: "timestamps", timestamps: "0" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + // PK magic + expect(dl.rawPayload[0]).toBe(0x50); + expect(dl.rawPayload[1]).toBe(0x4b); + }, 60_000); + + it("rejects timestamps mode with empty timestamps string", async () => { + const res = await runTool({ mode: "timestamps", timestamps: "" }); + expect(res.statusCode).toBe(400); + }); + + it("rejects a timestamp beyond the video duration with 422", async () => { + const res = await runTool({ mode: "timestamps", timestamps: "999" }); + expect(res.statusCode).toBe(422); + const body = JSON.parse(res.body); + expect(body.details || body.error).toMatch(/timestamp/i); + }); +}); diff --git a/tests/integration/video-to-webp.test.ts b/tests/integration/video-to-webp.test.ts new file mode 100644 index 00000000..4c485c7a --- /dev/null +++ b/tests/integration/video-to-webp.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { ffmpegAvailable } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/video-to-webp", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("video-to-webp (requires ffmpeg)", () => { + it("converts video to animated webp with RIFF/WEBP magic", async () => { + const res = await runTool({}); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + // Verify RIFF....WEBP magic bytes + const buf = dl.rawPayload; + const riff = buf.subarray(0, 4).toString("ascii"); + const webp = buf.subarray(8, 12).toString("ascii"); + expect(riff).toBe("RIFF"); + expect(webp).toBe("WEBP"); + }, 60_000); +}); diff --git a/tests/integration/watermark-video.test.ts b/tests/integration/watermark-video.test.ts new file mode 100644 index 00000000..4d305ff0 --- /dev/null +++ b/tests/integration/watermark-video.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const MP4 = readFileSync(join(__dirname, "..", "fixtures", "media", "tiny.mp4")); + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +async function runTool(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-video", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); +} + +describe.skipIf(!ffmpegAvailable())("watermark-video (requires ffmpeg)", () => { + it("watermarks a video and returns 200 with video content type", async () => { + const res = await runTool({ text: "CONFIDENTIAL" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + expect(dl.rawPayload.length).toBeGreaterThan(100); + + const tmpDir = mkdtempSync(join(tmpdir(), "wm-test-")); + const probeFile = join(tmpDir, "watermarked.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + }, 60_000); + + it("handles special characters and literal percent sequences in text", async () => { + const res = await runTool({ text: "a:b'c\\d,e %{pts} %{bad}" }); + expect(res.statusCode).toBe(200); + const envelope = JSON.parse(res.body); + expect(envelope.downloadUrl).toBeDefined(); + + const dl = await testApp.app.inject({ + method: "GET", + url: envelope.downloadUrl, + }); + expect(dl.statusCode).toBe(200); + + const tmpDir = mkdtempSync(join(tmpdir(), "wm-special-")); + const probeFile = join(tmpDir, "watermarked.mp4"); + writeFileSync(probeFile, dl.rawPayload); + const info = await probeMedia(probeFile); + expect(info.streams.some((s) => s.type === "video")).toBe(true); + }, 60_000); + + it("rejects empty text with 400", async () => { + const res = await runTool({ text: "" }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/unit/api/aspect-tools.test.ts b/tests/unit/api/aspect-tools.test.ts new file mode 100644 index 00000000..dcc9815a --- /dev/null +++ b/tests/unit/api/aspect-tools.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { canvasFor } from "../../../apps/api/src/routes/tools/aspect-pad.js"; + +describe("canvasFor", () => { + it("wide source into tall ratio (landscape into 9:16)", () => { + // 1920x1080 into 9:16 + const { cw, ch } = canvasFor(1920, 1080, 9, 16); + // Source is wider than 9:16, so height expands: ch = round(1920*16/9) = 3414 (even: 3414) + // cw stays 1920 (even) + expect(cw % 2).toBe(0); + expect(ch % 2).toBe(0); + expect(cw).toBe(1920); + expect(ch).toBe(Math.round((1920 * 16) / 9) + (Math.round((1920 * 16) / 9) % 2)); + }); + + it("tall source into wide ratio (portrait into 16:9)", () => { + // 1080x1920 into 16:9 + const { cw, ch } = canvasFor(1080, 1920, 16, 9); + // Source is taller than 16:9, so width expands: cw = round(1920*16/9) = 3414 (even: 3414) + // ch stays 1920 (even) + expect(cw % 2).toBe(0); + expect(ch % 2).toBe(0); + expect(ch).toBe(1920); + expect(cw).toBe(Math.round((1920 * 16) / 9) + (Math.round((1920 * 16) / 9) % 2)); + }); + + it("already matching ratio returns same-ish dims (both even)", () => { + const { cw, ch } = canvasFor(1920, 1080, 16, 9); + expect(cw % 2).toBe(0); + expect(ch % 2).toBe(0); + expect(cw).toBe(1920); + expect(ch).toBe(1080); + }); + + it("odd-dim source gets even output", () => { + const { cw, ch } = canvasFor(101, 101, 1, 1); + expect(cw % 2).toBe(0); + expect(ch % 2).toBe(0); + // 101 is odd, so cw should be 102 + expect(cw).toBe(102); + expect(ch).toBe(102); + }); + + it("small source into 4:3", () => { + const { cw, ch } = canvasFor(64, 48, 4, 3); + expect(cw % 2).toBe(0); + expect(ch % 2).toBe(0); + expect(cw).toBe(64); + expect(ch).toBe(48); + }); +}); diff --git a/tests/unit/api/video-speed.test.ts b/tests/unit/api/video-speed.test.ts new file mode 100644 index 00000000..282bd7b2 --- /dev/null +++ b/tests/unit/api/video-speed.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { buildAtempoChain } from "../../../apps/api/src/routes/tools/video-speed.js"; + +describe("buildAtempoChain", () => { + it("returns a single atempo for factor >= 0.5", () => { + expect(buildAtempoChain(2)).toBe("atempo=2"); + expect(buildAtempoChain(0.5)).toBe("atempo=0.5"); + expect(buildAtempoChain(1)).toBe("atempo=1"); + }); + + it("chains factors of 0.5 for values below 0.5", () => { + expect(buildAtempoChain(0.25)).toBe("atempo=0.5,atempo=0.5"); + }); + + it("chains correctly for very small factors", () => { + // 0.125 = 0.5 * 0.5 * 0.5 + expect(buildAtempoChain(0.125)).toBe("atempo=0.5,atempo=0.5,atempo=0.5"); + }); +}); diff --git a/tests/unit/media-engine/fonts.test.ts b/tests/unit/media-engine/fonts.test.ts new file mode 100644 index 00000000..c14c24c3 --- /dev/null +++ b/tests/unit/media-engine/fonts.test.ts @@ -0,0 +1,23 @@ +import { resolveFontFile } from "@snapotter/media-engine"; +import { describe, expect, it } from "vitest"; + +describe("resolveFontFile", () => { + it("honors SNAPOTTER_FONT_FILE + SNAPOTTER_FONT_FAMILY", () => { + process.env.SNAPOTTER_FONT_FILE = "/tmp/some.ttf"; + process.env.SNAPOTTER_FONT_FAMILY = "Some Family"; + try { + const f = resolveFontFile(); + expect(f).toEqual({ file: "/tmp/some.ttf", family: "Some Family" }); + } finally { + delete process.env.SNAPOTTER_FONT_FILE; + delete process.env.SNAPOTTER_FONT_FAMILY; + } + }); + + it("falls back to a real candidate on this machine", () => { + const f = resolveFontFile(); + expect(f).not.toBeNull(); + expect(f?.file.length).toBeGreaterThan(0); + expect(f?.family.length).toBeGreaterThan(0); + }); +});