diff --git a/Dockerfile b/Dockerfile index 77dbc757..b5c2e262 100644 --- a/Dockerfile +++ b/Dockerfile @@ -165,7 +165,8 @@ COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r requirements.txt && \ pip install openai-whisper && \ - pip install jsonschema + pip install jsonschema && \ + pip install yt-dlp # Create the appuser RUN useradd -m appuser @@ -197,4 +198,4 @@ gunicorn --bind 0.0.0.0:8080 \ chmod +x /app/run_gunicorn.sh # Run the shell script -CMD ["/app/run_gunicorn.sh"] \ No newline at end of file +CMD ["/app/run_gunicorn.sh"] diff --git a/README.md b/README.md index 4467429c..7cc12513 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - ![Original Logo Symbol](https://github.com/user-attachments/assets/75173cf4-2502-4710-998b-6b81740ae1bd) # No-Code Architects Toolkit API @@ -41,83 +40,326 @@ Each endpoint is supported by robust payload validation and detailed API documen - **[`/v1/audio/concatenate`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/audio/concatenate.md)** - Combines multiple audio files into a single audio file. + - Example Payload: + ```json + { + "audio_urls": [ + { "audio_url": "https://example.com/audio1.mp3" }, + { "audio_url": "https://example.com/audio2.mp3" } + ], + "webhook_url": "https://your-webhook-endpoint.com/callback", + "id": "custom-request-id-123" + } + ``` ### Code - **[`/v1/code/execute/python`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/code/execute/execute_python.md)** - Executes Python code remotely and returns the execution results. + - Example Payload: + ```json + { + "code": "print('Hello, World!')", + "timeout": 10, + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id" + } + ``` ### FFmpeg - **[`/v1/ffmpeg/compose`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/ffmpeg/ffmpeg_compose.md)** - Provides a flexible interface to FFmpeg for complex media processing operations. + - Example Payload (Note: See documentation for details on filter structure): + ```json + { + "inputs": [ + { "file_url": "https://example.com/video1.mp4" } + ], + "filters": [ + { "filter": "hflip" } + ], + "outputs": [ + { + "options": [ + { "option": "-c:v", "argument": "libx264" }, + { "option": "-crf", "argument": 23 } + ] + } + ], + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id" + } + ``` ### Image - **[`/v1/image/convert/video`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/image/convert/image_to_video.md)** - Transforms a static image into a video with custom duration and zoom effects. + - Example Payload: + ```json + { + "image_url": "https://example.com/image.jpg", + "length": 10, + "frame_rate": 24, + "zoom_speed": 5, + "orientation": "landscape", + "webhook_url": "https://example.com/webhook", + "id": "request-123" + } + ``` + +- **[`/v1/image/effects/video`](https://github.com/fahimanwer/no-code-architects-toolkit/blob/main/docs/image/effects/image_effects_video.md)** + - Creates a video from an image with dynamic effects like zoom-in-out or panning. + - Example Payload (Zoom In/Out): + ```json + { + "image_url": "https://example.com/image.jpg", + "effect_type": "zoom_in_out", + "length": 10, + "orientation": "landscape", + "id": "request-zoom" + } + ``` ### Media - **[`/v1/media/convert`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/media/convert/media_convert.md)** - Converts media files from one format to another with customizable codec options. + - Example Payload: + ```json + { + "media_url": "https://example.com/video.mp4", + "format": "avi", + "video_codec": "libx264", + "audio_codec": "aac", + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id" + } + ``` - **[`/v1/media/convert/mp3`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/media/convert/media_to_mp3.md)** - Converts various media formats specifically to MP3 audio. + - Example Payload: + ```json + { + "media_url": "https://example.com/video.mp4", + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id", + "bitrate": "192k" + } + ``` - **[`/v1/BETA/media/download`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/media/download.md)** - Downloads media content from various online sources using yt-dlp. + - Example Payload: + ```json + { + "media_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webhook_url": "https://example.com/webhook", + "id": "custom-request-123", + "format": { + "quality": "best", + "resolution": "720p" + }, + "audio": { + "extract": true, + "format": "mp3" + } + } + ``` - **[`/v1/media/feedback`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/media/feedback.md)** - - Provides a web interface for collecting and displaying feedback on media content. + - Provides a web interface for collecting and displaying feedback on media content. (GET endpoint, no JSON payload) - **[`/v1/media/transcribe`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/media/media_transcribe.md)** - Transcribes or translates audio/video content from a provided media URL. + - Example Payload: + ```json + { + "media_url": "https://example.com/media/file.mp3", + "task": "transcribe", + "include_text": true, + "include_srt": true, + "response_type": "cloud", + "webhook_url": "https://your-webhook.com/callback", + "id": "custom-job-123" + } + ``` - **[`/v1/media/silence`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/media/silence.md)** - Detects silence intervals in a given media file. + - Example Payload: + ```json + { + "media_url": "https://example.com/audio.mp3", + "start": "00:00:10.0", + "end": "00:01:00.0", + "duration": 0.5, + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id" + } + ``` - **[`/v1/media/metadata`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/media/metadata.md)** - Extracts comprehensive metadata from media files including format, codecs, resolution, and bitrates. + - Example Payload: + ```json + { + "media_url": "https://example.com/media.mp4", + "webhook_url": "https://example.com/webhook", + "id": "custom-id" + } + ``` ### S3 - **[`/v1/s3/upload`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/s3/upload.md)** - Uploads files to Amazon S3 storage by streaming directly from a URL. + - Example Payload: + ```json + { + "file_url": "https://example.com/path/to/file.mp4", + "filename": "custom-name.mp4", + "public": true + } + ``` ### Toolkit - **[`/v1/toolkit/authenticate`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/toolkit/authenticate.md)** - - Provides a simple authentication mechanism to validate API keys. + - Provides a simple authentication mechanism to validate API keys. (GET endpoint, uses `X-API-Key` header, no JSON payload) - **[`/v1/toolkit/test`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/toolkit/test.md)** - - Verifies that the NCA Toolkit API is properly installed and functioning. + - Verifies that the NCA Toolkit API is properly installed and functioning. (GET endpoint, uses `X-API-Key` header, no JSON payload) - **[`/v1/toolkit/job/status`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/toolkit/job_status.md)** - Retrieves the status of a specific job by its ID. + - Example Payload: + ```json + { + "job_id": "e6d7f3c0-9c9f-4b8a-b7c3-f0e3c9f6b9d7" + } + ``` - **[`/v1/toolkit/jobs/status`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/toolkit/jobs_status.md)** - Retrieves the status of all jobs within a specified time range. + - Example Payload (Optional): + ```json + { + "since_seconds": 3600 + } + ``` ### Video - **[`/v1/video/caption`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/video/caption_video.md)** - Adds customizable captions to videos with various styling options. + - Example Payload (Auto-transcribe): + ```json + { + "video_url": "https://example.com/video.mp4", + "settings": { + "style": "karaoke", + "line_color": "#FFFFFF", + "word_color": "#FFFF00" + }, + "webhook_url": "https://example.com/webhook" + } + ``` - **[`/v1/video/concatenate`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/video/concatenate.md)** - Combines multiple videos into a single continuous video file. + - Example Payload: + ```json + { + "video_urls": [ + {"video_url": "https://example.com/video1.mp4"}, + {"video_url": "https://example.com/video2.mp4"} + ], + "webhook_url": "https://example.com/webhook", + "id": "request-123" + } + ``` - **[`/v1/video/thumbnail`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/video/thumbnail.md)** - Extracts a thumbnail image from a specific timestamp in a video. + - Example Payload: + ```json + { + "video_url": "https://example.com/video.mp4", + "second": 30, + "webhook_url": "https://your-service.com/webhook", + "id": "custom-request-123" + } + ``` - **[`/v1/video/cut`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/video/cut.md)** - Cuts specified segments from a video file with optional encoding settings. + - Example Payload: + ```json + { + "video_url": "https://example.com/video.mp4", + "cuts": [ + { + "start": "00:00:10.000", + "end": "00:00:20.000" + }, + { + "start": "00:00:30.000", + "end": "00:00:40.000" + } + ], + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id" + } + ``` - **[`/v1/video/split`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/video/split.md)** - Splits a video into multiple segments based on specified start and end times. + - Example Payload: + ```json + { + "video_url": "https://example.com/video.mp4", + "splits": [ + { + "start": "00:00:10.000", + "end": "00:00:20.000" + }, + { + "start": "00:00:30.000", + "end": "00:00:40.000" + } + ], + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id" + } + ``` - **[`/v1/video/trim`](https://github.com/stephengpope/no-code-architects-toolkit/blob/main/docs/video/trim.md)** - Trims a video by keeping only the content between specified start and end times. + - Example Payload: + ```json + { + "video_url": "https://example.com/video.mp4", + "start": "00:01:00", + "end": "00:03:00", + "webhook_url": "https://example.com/webhook", + "id": "unique-request-id" + } + ``` + +- **[`/v1/video/get-scenes`](https://github.com/fahimanwer/no-code-architects-toolkit/blob/main/docs/video/get_scenes.md)** + - Extracts frames (thumbnails) from multiple specified timestamps in a video. + - Example Payload: + ```json + { + "video_url": "https://example.com/my_video.mp4", + "timestamps": [5, 15.5, 25], + "id": "local-video-scenes", + "webhook_url": "https://example.com/webhook" + } + ``` --- @@ -295,6 +537,82 @@ You can more easily control performance and cost this way, but requires more tec --- +## Development Workflow & Disk Space Management + +Developing new features or fixing bugs typically involves building and testing Docker images. Over time, this can lead to an accumulation of old images and build cache, consuming significant disk space. Here's a recommended workflow and tips for managing disk usage: + +### Managing Docker Disk Space + +It's good practice to periodically clean up unused Docker resources: + +* **Prune unused images, containers, and networks:** + ```bash + docker system prune -a + ``` + (Use with caution: this will remove all stopped containers, all networks not used by at least one container, all dangling images, and all dangling build cache.) + +* **List images to identify old/large ones:** + ```bash + docker images + ``` + +* **Remove specific images by ID or tag:** + ```bash + docker rmi + ``` + +* **Prune build cache:** + ```bash + docker builder prune + ``` + +### Feature Development Workflow + +1. **Create a new branch:** Start by creating a new branch in your local Git repository for the feature or fix (e.g., `git checkout -b feature/my-new-endpoint`). +2. **Implement changes:** Make your code changes (e.g., add new service logic, routes, update `app.py`). +3. **Update Documentation:** Create or update any relevant documentation files in the `/docs` directory and ensure the main `README.md` is updated to reflect new endpoints or significant changes in behavior, including example payloads. +4. **Local Testing:** + * **Build a local Docker image:** + ```bash + docker build -t nca-toolkit-dev . + ``` + (Use a temporary tag like `nca-toolkit-dev` to avoid conflicting with official tags). + * **Run the local image:** + ```bash + docker run -d -p 8080:8080 --name nca-toolkit-test -e API_KEY=your_test_key [other_env_vars_as_needed] nca-toolkit-dev + ``` + * Thoroughly test the new feature using Postman or cURL. Check container logs (`docker logs nca-toolkit-test -f`) for errors. + * Stop and remove the test container: `docker stop nca-toolkit-test && docker rm nca-toolkit-test`. +5. **Commit Changes:** Once satisfied, commit your changes to your feature branch with clear commit messages. +6. **Push to GitHub:** + * Push your feature branch to your fork on GitHub. + * Create a Pull Request against the `main` branch of the `fahimanwer/no-code-architects-toolkit` repository (or the appropriate upstream repository if contributing to `stephengpope/no-code-architects-toolkit`). +7. **Update Docker Hub (After PR Merge - Optional for Personal Use):** + * Once your changes are merged into the `main` branch of your primary repository (e.g., `fahimanwer/no-code-architects-toolkit`), you can build the official image and push it to your Docker Hub. + * Ensure your local `main` branch is up to date: `git checkout main && git pull origin main`. + * **Build the image:** + ```bash + docker build -t yourdockerhubusername/repositoryname:latest -t yourdockerhubusername/repositoryname:version_tag . + # Example: + # docker build -t fahimanwer18/nca-toolkit-fahim:latest -t fahimanwer18/nca-toolkit-fahim:0.2.0 . + ``` + * **Log in to Docker Hub:** + ```bash + docker login + ``` + * **Push the image:** + ```bash + docker push yourdockerhubusername/repositoryname:latest + docker push yourdockerhubusername/repositoryname:version_tag + # Example: + # docker push fahimanwer18/nca-toolkit-fahim:latest + # docker push fahimanwer18/nca-toolkit-fahim:0.2.0 + ``` + +By following these steps, you can keep your development environment clean and ensure that your GitHub and Docker Hub repositories stay synchronized with tested features. + +--- + ## Contributing To the NCA Toolkit API We welcome contributions from the public! If you'd like to contribute to this project, please follow these steps: diff --git a/app.py b/app.py index 2a196f82..e7822795 100644 --- a/app.py +++ b/app.py @@ -237,6 +237,7 @@ def wrapper(*args, **kwargs): from routes.v1.media.metadata import v1_media_metadata_bp from routes.v1.toolkit.job_status import v1_toolkit_job_status_bp from routes.v1.toolkit.jobs_status import v1_toolkit_jobs_status_bp + from routes.v1.image.effects.image_effects_video import v1_image_effects_video_bp app.register_blueprint(v1_ffmpeg_compose_bp) app.register_blueprint(v1_media_transcribe_bp) @@ -265,10 +266,21 @@ def wrapper(*args, **kwargs): app.register_blueprint(v1_media_metadata_bp) app.register_blueprint(v1_toolkit_job_status_bp) app.register_blueprint(v1_toolkit_jobs_status_bp) + app.register_blueprint(v1_image_effects_video_bp) # Register new blueprint return app -app = create_app() -if __name__ == '__main__': - app.run(host='0.0.0.0', port=8080) \ No newline at end of file + +if __name__ == "__main__": + # Use environment variables or defaults for Gunicorn settings + bind_ip = os.environ.get('GUNICORN_BIND_IP', '0.0.0.0') + bind_port = os.environ.get('GUNICORN_BIND_PORT', '8080') + workers = os.environ.get('GUNICORN_WORKERS', '4') # Default to 4 workers + timeout = os.environ.get('GUNICORN_TIMEOUT', '300') # Default to 300 seconds timeout + + # Command to run Gunicorn + gunicorn_command = f"gunicorn -w {workers} -b {bind_ip}:{bind_port} -t {timeout} app:create_app()" + + # Execute the Gunicorn command + os.system(gunicorn_command) diff --git a/docs/image/convert/image_to_video.md b/docs/image/convert/image_to_video.md index 376be039..a9624b02 100644 --- a/docs/image/convert/image_to_video.md +++ b/docs/image/convert/image_to_video.md @@ -25,6 +25,7 @@ The request body must be in JSON format and should include the following paramet | `length` | number | No | The desired length of the video in seconds (default: 5). | | `frame_rate`| integer| No | The frame rate of the output video (default: 30). | | `zoom_speed`| number | No | The speed of the zoom effect (0-100, default: 3). | +| `orientation`| string | No | Desired output video orientation: "landscape" (1920x1080) or "portrait" (1080x1920). Defaults to landscape if omitted or invalid. | | `webhook_url`| string| No | The URL to receive a webhook notification upon completion. | | `id` | string | No | An optional identifier for the request. | @@ -35,9 +36,10 @@ The `validate_payload` decorator in the `routes.v1.image.convert.image_to_video` "type": "object", "properties": { "image_url": {"type": "string", "format": "uri"}, - "length": {"type": "number", "minimum": 1, "maximum": 60}, + "length": {"type": "number", "minimum": 0.1, "maximum": 60}, "frame_rate": {"type": "integer", "minimum": 15, "maximum": 60}, "zoom_speed": {"type": "number", "minimum": 0, "maximum": 100}, + "orientation": {"type": "string", "enum": ["landscape", "portrait"]}, "webhook_url": {"type": "string", "format": "uri"}, "id": {"type": "string"} }, @@ -54,6 +56,7 @@ The `validate_payload` decorator in the `routes.v1.image.convert.image_to_video` "length": 10, "frame_rate": 24, "zoom_speed": 5, + "orientation": "landscape", "webhook_url": "https://example.com/webhook", "id": "request-123" } @@ -63,7 +66,7 @@ The `validate_payload` decorator in the `routes.v1.image.convert.image_to_video` curl -X POST \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ - -d '{"image_url": "https://example.com/image.jpg", "length": 10, "frame_rate": 24, "zoom_speed": 5, "webhook_url": "https://example.com/webhook", "id": "request-123"}' \ + -d '{"image_url": "https://example.com/image.jpg", "length": 10, "frame_rate": 24, "zoom_speed": 5, "orientation": "landscape", "webhook_url": "https://example.com/webhook", "id": "request-123"}' \ http://your-api-endpoint/v1/image/convert/video ``` @@ -139,9 +142,10 @@ The endpoint handles the following types of errors: ## 6. Usage Notes - The `image_url` parameter must be a valid URL pointing to an image file. -- The `length` parameter specifies the duration of the output video in seconds and must be between 1 and 60. +- The `length` parameter specifies the duration of the output video in seconds and must be between 0.1 and 60. - The `frame_rate` parameter specifies the frame rate of the output video and must be between 15 and 60. - The `zoom_speed` parameter controls the speed of the zoom effect and must be between 0 and 100. +- The `orientation` parameter (optional) forces the output to "landscape" (1920x1080) or "portrait" (1080x1920). Defaults to landscape. - The `webhook_url` parameter is optional and can be used to receive a notification when the conversion is complete. - The `id` parameter is optional and can be used to identify the request. @@ -156,4 +160,4 @@ The endpoint handles the following types of errors: - Validate the `image_url` parameter before sending the request to ensure it points to a valid and accessible image file. - Use the `webhook_url` parameter to receive notifications about the completion of the conversion process, rather than polling the API repeatedly. - Provide the `id` parameter to easily identify and track the request in logs or notifications. -- Consider setting the `bypass_queue` parameter to `True` for time-sensitive requests to bypass the queue and process the request immediately. \ No newline at end of file +- Consider setting the `bypass_queue` parameter to `True` for time-sensitive requests to bypass the queue and process the request immediately. diff --git a/docs/image/effects/image_effects_video.md b/docs/image/effects/image_effects_video.md new file mode 100644 index 00000000..2e0a410f --- /dev/null +++ b/docs/image/effects/image_effects_video.md @@ -0,0 +1,100 @@ +# Image Effects to Video Conversion + +## 1. Overview + +The `/v1/image/effects/video` endpoint converts an image into a video file with specific dynamic effects like zoom-in-out or panning. It's registered in `app.py` under the `v1_image_effects_video_bp` blueprint from `routes.v1.image.effects.image_effects_video`. + +## 2. Endpoint + +**URL Path:** `/v1/image/effects/video` +**HTTP Method:** `POST` + +## 3. Request + +### Headers + +- `x-api-key` (required): The API key for authentication. + +### Body Parameters + +The request body must be in JSON format: + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------------------------------------------| +| `image_url` | string | Yes | The URL of the image to be converted. | +| `effect_type` | string | Yes | The desired effect: "zoom_in_out" or "pan_rlr" (pan right-left-right). | +| `length` | number | No | Desired video length in seconds (default: 5, max: 120). | +| `frame_rate` | integer | No | Frame rate of the output video (default: 30, min: 15, max: 60). | +| `orientation` | string | No | Desired output video orientation: "landscape" (1920x1080) or "portrait" (1080x1920). Default: landscape. | +| `webhook_url` | string | No | URL to receive a webhook notification upon completion. | +| `id` | string | No | Optional identifier for the request. | + +The `validate_payload` decorator enforces this JSON schema: + +```json +{ + "type": "object", + "properties": { + "image_url": {"type": "string", "format": "uri"}, + "effect_type": {"type": "string", "enum": ["zoom_in_out", "pan_rlr"]}, + "length": {"type": "number", "minimum": 0.1, "maximum": 120}, + "frame_rate": {"type": "integer", "minimum": 15, "maximum": 60}, + "orientation": {"type": "string", "enum": ["landscape", "portrait"]}, + "webhook_url": {"type": "string", "format": "uri"}, + "id": {"type": "string"} + }, + "required": ["image_url", "effect_type"], + "additionalProperties": False +} +``` + +### Example Request (Zoom In/Out) + +```json +{ + "image_url": "https://example.com/image.jpg", + "effect_type": "zoom_in_out", + "length": 10, + "orientation": "landscape", + "id": "request-zoom" +} +``` + +### Example Request (Pan Right-Left-Right) + +```json +{ + "image_url": "https://example.com/image.jpg", + "effect_type": "pan_rlr", + "length": 15, + "orientation": "portrait", + "id": "request-pan" +} +``` + +## 4. Response + +Success and error responses follow the same structure as other endpoints (e.g., `/v1/image/convert/video`), returning the cloud storage URL in the `response` field on success (200 OK), or appropriate error codes (400, 429, 500). + +## 5. Error Handling + +- Handles missing/invalid parameters (400). +- Handles queue length exceeded (429). +- Handles exceptions during processing (500). + +## 6. Usage Notes + +- `effect_type` is required and determines the animation applied. +- `zoom_in_out`: Zooms in towards the center for the first half of the `length`, then zooms out for the second half. +- `pan_rlr`: Pans the view across the image from right-to-left for the first half, then left-to-right for the second half. No zoom is applied. +- The maximum `length` is increased to 120 seconds for this endpoint. + +## 7. Common Issues + +- Providing an invalid `image_url`. +- Forgetting the required `effect_type` parameter. + +## 8. Best Practices + +- Use `webhook_url` for long processing times. +- Specify `orientation` if a specific output aspect ratio is needed. diff --git a/routes/image_to_video.py b/routes/image_to_video.py index 5a223720..700fcc74 100644 --- a/routes/image_to_video.py +++ b/routes/image_to_video.py @@ -32,7 +32,7 @@ "type": "object", "properties": { "image_url": {"type": "string", "format": "uri"}, - "length": {"type": "number", "minimum": 1, "maximum": 60}, + "length": {"type": "number", "minimum": 1, "maximum": 300}, "frame_rate": {"type": "integer", "minimum": 15, "maximum": 60}, "zoom_speed": {"type": "number", "minimum": 0, "maximum": 100}, "webhook_url": {"type": "string", "format": "uri"}, diff --git a/routes/v1/image/convert/image_to_video.py b/routes/v1/image/convert/image_to_video.py index a9dd226b..b266050c 100644 --- a/routes/v1/image/convert/image_to_video.py +++ b/routes/v1/image/convert/image_to_video.py @@ -36,6 +36,7 @@ "length": {"type": "number", "minimum": 0.1, "maximum": 60}, "frame_rate": {"type": "integer", "minimum": 15, "maximum": 60}, "zoom_speed": {"type": "number", "minimum": 0, "maximum": 100}, + "orientation": {"type": "string", "enum": ["landscape", "portrait"]}, "webhook_url": {"type": "string", "format": "uri"}, "id": {"type": "string"} }, @@ -48,6 +49,7 @@ def image_to_video(job_id, data): length = data.get('length', 5) frame_rate = data.get('frame_rate', 30) zoom_speed = data.get('zoom_speed', 3) / 100 + orientation = data.get('orientation') webhook_url = data.get('webhook_url') id = data.get('id') @@ -56,7 +58,7 @@ def image_to_video(job_id, data): try: # Process image to video conversion output_filename = process_image_to_video( - image_url, length, frame_rate, zoom_speed, job_id, webhook_url + image_url, length, frame_rate, zoom_speed, job_id, webhook_url, orientation ) # Upload the resulting file using the unified upload_file() method @@ -71,3 +73,4 @@ def image_to_video(job_id, data): except Exception as e: logger.error(f"Job {job_id}: Error processing image to video: {str(e)}", exc_info=True) return str(e), "/v1/image/convert/video", 500 + diff --git a/routes/v1/image/effects/image_effects_video.py b/routes/v1/image/effects/image_effects_video.py new file mode 100644 index 00000000..d8269f89 --- /dev/null +++ b/routes/v1/image/effects/image_effects_video.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025 Stephen G. Pope +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from flask import Blueprint +from app_utils import * +import logging +from services.v1.image.effects.image_effects_video import process_image_effects_video +from services.authentication import authenticate +from services.cloud_storage import upload_file + +v1_image_effects_video_bp = Blueprint('v1_image_effects_video', __name__) +logger = logging.getLogger(__name__) + +@v1_image_effects_video_bp.route('/v1/image/effects/video', methods=['POST']) +@authenticate +@validate_payload({ + "type": "object", + "properties": { + "image_url": {"type": "string", "format": "uri"}, + "effect_type": {"type": "string", "enum": ["zoom_in_out", "pan_rlr"]}, + "length": {"type": "number", "minimum": 0.1, "maximum": 120}, # Increased max length + "frame_rate": {"type": "integer", "minimum": 15, "maximum": 60}, + "orientation": {"type": "string", "enum": ["landscape", "portrait"]}, + "webhook_url": {"type": "string", "format": "uri"}, + "id": {"type": "string"} + }, + "required": ["image_url", "effect_type"], # effect_type is required + "additionalProperties": False +}) +@queue_task_wrapper(bypass_queue=False) +def image_effects_video(job_id, data): + image_url = data.get('image_url') + effect_type = data.get('effect_type') + length = data.get('length', 5) + frame_rate = data.get('frame_rate', 30) + orientation = data.get('orientation') + webhook_url = data.get('webhook_url') + id = data.get('id') + + logger.info(f"Job {job_id}: Received image effects video request for {image_url} with effect {effect_type}") + + try: + # Process image to video conversion with effects + output_filename = process_image_effects_video( + image_url, length, frame_rate, effect_type, job_id, webhook_url, orientation + ) + + # Upload the resulting file using the unified upload_file() method + cloud_url = upload_file(output_filename) + + # Log the successful upload + logger.info(f"Job {job_id}: Converted effects video uploaded to cloud storage: {cloud_url}") + + # Return the cloud URL for the uploaded file + return cloud_url, "/v1/image/effects/video", 200 + + except Exception as e: + logger.error(f"Job {job_id}: Error processing image effects video: {str(e)}", exc_info=True) + return str(e), "/v1/image/effects/video", 500 diff --git a/services/v1/image/convert/image_to_video.py b/services/v1/image/convert/image_to_video.py index 7bce0481..52afdb5f 100644 --- a/services/v1/image/convert/image_to_video.py +++ b/services/v1/image/convert/image_to_video.py @@ -24,27 +24,37 @@ from config import LOCAL_STORAGE_PATH logger = logging.getLogger(__name__) -def process_image_to_video(image_url, length, frame_rate, zoom_speed, job_id, webhook_url=None): +def process_image_to_video(image_url, length, frame_rate, zoom_speed, job_id, webhook_url=None, orientation=None): try: # Download the image file image_path = download_file(image_url, LOCAL_STORAGE_PATH) logger.info(f"Downloaded image to {image_path}") - # Get image dimensions using Pillow - with Image.open(image_path) as img: - width, height = img.size - logger.info(f"Original image dimensions: {width}x{height}") - # Prepare the output path output_path = os.path.join(LOCAL_STORAGE_PATH, f"{job_id}.mp4") - # Determine orientation and set appropriate dimensions - if width > height: - scale_dims = "7680:4320" - output_dims = "1920x1080" - else: + # Determine orientation and set appropriate dimensions based on parameter or default to landscape + if orientation == 'portrait': scale_dims = "4320:7680" output_dims = "1080x1920" + logger.info(f"Using specified portrait orientation.") + else: + # Default to landscape if orientation is not specified or invalid + if orientation != 'landscape' and orientation is not None: + logger.warning(f"Invalid orientation specified: '{orientation}'. Defaulting to landscape.") + else: + logger.info(f"Using landscape orientation (specified or default).") + orientation = 'landscape' # Ensure orientation variable is set for logging/potential future use + scale_dims = "7680:4320" + output_dims = "1920x1080" + + # Get image dimensions using Pillow (for logging/potential future use, not for dimension setting) + try: + with Image.open(image_path) as img: + width, height = img.size + logger.info(f"Original image dimensions: {width}x{height}") + except Exception as img_err: + logger.warning(f"Could not read image dimensions: {img_err}") # Calculate total frames and zoom factor total_frames = int(length * frame_rate) @@ -77,4 +87,4 @@ def process_image_to_video(image_url, length, frame_rate, zoom_speed, job_id, we return output_path except Exception as e: logger.error(f"Error in process_image_to_video: {str(e)}", exc_info=True) - raise \ No newline at end of file + raise diff --git a/services/v1/image/effects/image_effects_video.py b/services/v1/image/effects/image_effects_video.py new file mode 100644 index 00000000..25a837b8 --- /dev/null +++ b/services/v1/image/effects/image_effects_video.py @@ -0,0 +1,114 @@ +# Copyright (c) 2025 Stephen G. Pope +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os +import subprocess +import logging +from services.file_management import download_file +from PIL import Image +from config import LOCAL_STORAGE_PATH +logger = logging.getLogger(__name__) + +def process_image_effects_video(image_url, length, frame_rate, effect_type, job_id, webhook_url=None, orientation=None): + """ + Generates a video from an image with specific effects like zoom-in-out or panning. + """ + try: + # Download the image file + image_path = download_file(image_url, LOCAL_STORAGE_PATH) + logger.info(f"Job {job_id}: Downloaded image for effects video to {image_path}") + + # Prepare the output path + output_path = os.path.join(LOCAL_STORAGE_PATH, f"{job_id}.mp4") + + # Determine output dimensions based on orientation or default to landscape + if orientation == 'portrait': + scale_dims = "4320:7680" # High-res intermediate for portrait + output_dims = "1080x1920" # Final output dimensions + ow, oh = 1080, 1920 + iw, ih = 4320, 7680 # Input dimensions *to zoompan filter* after initial scale + logger.info(f"Job {job_id}: Using specified portrait orientation.") + else: + # Default to landscape + if orientation != 'landscape' and orientation is not None: + logger.warning(f"Job {job_id}: Invalid orientation specified: '{orientation}'. Defaulting to landscape.") + orientation = 'landscape' + scale_dims = "7680:4320" # High-res intermediate for landscape + output_dims = "1920x1080" # Final output dimensions + ow, oh = 1920, 1080 + iw, ih = 7680, 4320 # Input dimensions *to zoompan filter* after initial scale + logger.info(f"Job {job_id}: Using landscape orientation (specified or default).") + + # Calculate total frames and midpoint + total_frames = int(length * frame_rate) + mid_frame = total_frames / 2 + + # Build the zoompan filter string based on effect_type + vf_filter = "" + if effect_type == "zoom_in_out": + max_zoom = 1.5 # Define how much to zoom in + # Expression: Zoom in linearly to max_zoom until mid_frame, then zoom out linearly back to 1 + zoom_expr = f"if(lt(on,{mid_frame}), 1+(({max_zoom}-1)*on/{mid_frame}), {max_zoom}-(({max_zoom}-1)*(on-{mid_frame})/{mid_frame}))" + vf_filter = f"scale={scale_dims}:force_original_aspect_ratio=decrease,pad={iw}:{ih}:(ow-iw)/2:(oh-ih)/2,zoompan=z='{zoom_expr}':d={total_frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={output_dims},fps={frame_rate}" + logger.info(f"Job {job_id}: Using zoom_in_out effect.") + + elif effect_type == "pan_rlr": + # Ensure zoom is 1 (no zoom for panning) + # Expression: Pan x from right (iw-ow) to left (0) until mid_frame, then pan x from left (0) to right (iw-ow) + pan_x_expr = f"if(lt(on,{mid_frame}), ({iw}-{ow})*(1-on/{mid_frame}), ({iw}-{ow})*((on-{mid_frame})/{mid_frame}))" + vf_filter = f"scale={scale_dims}:force_original_aspect_ratio=decrease,pad={iw}:{ih}:(ow-iw)/2:(oh-ih)/2,zoompan=z=1:d={total_frames}:x='{pan_x_expr}':y='(ih-{oh})/2':s={output_dims},fps={frame_rate}" + logger.info(f"Job {job_id}: Using pan_rlr (right-left-right) effect.") + else: + # Default or fallback: Simple zoom-in (copied from original function) + zoom_speed_default = 0.03 # Default zoom speed if needed + zoom_factor = 1 + (zoom_speed_default * length) + vf_filter = f"scale={scale_dims}:force_original_aspect_ratio=decrease,pad={iw}:{ih}:(ow-iw)/2:(oh-ih)/2,zoompan=z='min(1+({zoom_speed_default}*on/{frame_rate}),{zoom_factor})':d={total_frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={output_dims},fps={frame_rate}" + logger.warning(f"Job {job_id}: Unknown effect_type '{effect_type}'. Defaulting to simple zoom-in.") + + + # Prepare FFmpeg command + cmd = [ + 'ffmpeg', '-framerate', str(frame_rate), '-loop', '1', '-i', image_path, + '-vf', vf_filter, + '-c:v', 'libx264', '-r', str(frame_rate), '-t', str(length), '-pix_fmt', 'yuv420p', output_path + ] + + logger.info(f"Job {job_id}: Running FFmpeg command: {' '.join(cmd)}") + + # Run FFmpeg command + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + logger.error(f"Job {job_id}: FFmpeg command failed. Error: {result.stderr}") + # Clean up input file even on error + if os.path.exists(image_path): + os.remove(image_path) + raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr) + + logger.info(f"Job {job_id}: Video created successfully: {output_path}") + + # Clean up input file + os.remove(image_path) + + return output_path + except Exception as e: + logger.error(f"Job {job_id}: Error in process_image_effects_video: {str(e)}", exc_info=True) + # Clean up potentially downloaded input file on error + if 'image_path' in locals() and os.path.exists(image_path): + try: + os.remove(image_path) + except OSError as rm_err: + logger.error(f"Job {job_id}: Error cleaning up input file {image_path}: {rm_err}") + raise diff --git a/services/v1/video/get_scenes.py b/services/v1/video/get_scenes.py new file mode 100644 index 00000000..8f2c3b84 --- /dev/null +++ b/services/v1/video/get_scenes.py @@ -0,0 +1,190 @@ +# Copyright (c) 2025 Stephen G. Pope +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os +import subprocess +import logging +import re +from services.file_management import download_file +from services.cloud_storage import upload_file +from config import LOCAL_STORAGE_PATH + +logger = logging.getLogger(__name__) + +def is_youtube_url(url): + """Check if the URL is a YouTube URL.""" + # Explicitly double backslashes for escapes in regex + youtube_regex = ( + r'(https?://)?(www\.)?' # Optional http/https and www. + r'(youtube|youtu|youtube-nocookie)\.(com|be)/' # Domain + r'(watch\?v=|embed/|v/|.+\?v=|live/)?' # Optional path parts + r'([^&=%\?]{11})' # Video ID (11 chars excluding & = % ?) + ) + return re.match(youtube_regex, url) is not None + +def download_youtube_video(url, output_template, job_id): + """Downloads video (no audio) from YouTube using yt-dlp.""" + output_path = output_template.format(job_id=job_id, timestamp="video", ext="mp4") # Predictable name + # -f 'bv*[ext=mp4]': best video-only stream with mp4 extension + # --no-audio: Explicitly try to avoid audio download/muxing if separate + # Using a temporary template first to get the final name + temp_output_template = os.path.join(LOCAL_STORAGE_PATH, f"{job_id}_temp.%(ext)s") + final_output_path = os.path.join(LOCAL_STORAGE_PATH, f"{job_id}_video.mp4") + + cmd = [ + 'yt-dlp', + '-f', 'bv*[ext=mp4]', # Best video-only mp4 + '--no-audio', # Attempt to ensure no audio track included + '--output', temp_output_template, + url + ] + logger.info(f"Job {job_id}: Running yt-dlp command: {' '.join(cmd)}") + try: + result = subprocess.run(cmd, check=True, capture_output=True, text=True) + logger.info(f"Job {job_id}: yt-dlp stdout: {result.stdout}") + logger.info(f"Job {job_id}: yt-dlp stderr: {result.stderr}") + + # Find the actually downloaded file (yt-dlp might adjust extension) + downloaded_file = None + for f in os.listdir(LOCAL_STORAGE_PATH): + if f.startswith(f"{job_id}_temp"): + downloaded_file = os.path.join(LOCAL_STORAGE_PATH, f) + break + + if not downloaded_file or not os.path.exists(downloaded_file): + raise Exception("yt-dlp completed but output file not found.") + + # Rename to predictable final path + os.rename(downloaded_file, final_output_path) + logger.info(f"Job {job_id}: YouTube video downloaded and renamed to {final_output_path}") + return final_output_path + + except subprocess.CalledProcessError as e: + logger.error(f"Job {job_id}: yt-dlp command failed. Error: {e.stderr}") + raise Exception(f"yt-dlp failed: {e.stderr}") + except Exception as e: + logger.error(f"Job {job_id}: Error during YouTube download: {str(e)}") + raise + +def process_get_scenes(video_url, timestamps, job_id): + """ + Downloads a video (handles YouTube URLs) and extracts frames at specified timestamps. + Uploads frames to cloud storage and returns their URLs. + """ + local_video_path = None + generated_files = [] + scene_urls = [] + + output_template = os.path.join(LOCAL_STORAGE_PATH, f"{job_id}_scene_{{timestamp}}.jpg") + + try: + # --- 1. Download Video --- + if is_youtube_url(video_url): + logger.info(f"Job {job_id}: Detected YouTube URL. Downloading with yt-dlp...") + local_video_path = download_youtube_video(video_url, output_template, job_id) + else: + logger.info(f"Job {job_id}: Downloading standard URL...") + local_video_path = download_file(video_url, LOCAL_STORAGE_PATH) + # Rename downloaded file for consistency if needed (optional) + base, ext = os.path.splitext(local_video_path) + consistent_path = os.path.join(LOCAL_STORAGE_PATH, f"{job_id}_video{ext}") + if local_video_path != consistent_path: + os.rename(local_video_path, consistent_path) + local_video_path = consistent_path + logger.info(f"Job {job_id}: Video downloaded to {local_video_path}") + + if not local_video_path or not os.path.exists(local_video_path): + raise Exception("Video download failed or file not found.") + + # --- 2. Extract Frames --- + unique_timestamps = sorted(list(set(timestamps))) # Ensure uniqueness and order + + for ts in unique_timestamps: + if not isinstance(ts, (int, float)) or ts < 0: + logger.warning(f"Job {job_id}: Skipping invalid timestamp {ts}") + continue + + # Ensure timestamp string is safe for filenames + ts_str = str(ts).replace('.', '_') + scene_filename = output_template.format(timestamp=ts_str) + generated_files.append(scene_filename) + + # Use -ss before -i for faster seeking + cmd = [ + 'ffmpeg', + '-ss', str(ts), + '-i', local_video_path, + '-vframes', '1', + '-q:v', '2', # Good quality JPEG + scene_filename + ] + logger.info(f"Job {job_id}: Extracting frame at {ts}s: {' '.join(cmd)}") + try: + result = subprocess.run(cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as e: + # Log error but try to continue with other timestamps + logger.error(f"Job {job_id}: Failed to extract frame at {ts}s. Error: {e.stderr}") + # Remove potentially incomplete file + if os.path.exists(scene_filename): + os.remove(scene_filename) + generated_files.remove(scene_filename) # Don't try to upload it + + # --- 3. Upload Frames --- + logger.info(f"Job {job_id}: Uploading {len(generated_files)} extracted scenes...") + for scene_file in generated_files: + if os.path.exists(scene_file): + try: + cloud_url = upload_file(scene_file) + # Extract timestamp safely from filename + try: + base_name = os.path.basename(scene_file) + # Split by the last occurrence of '_scene_' before the extension + parts = base_name.rsplit('_scene_', 1) + if len(parts) == 2: + ts_part = parts[1].replace('.jpg', '') + original_ts = float(ts_part.replace('_', '.')) + scene_urls.append({"timestamp": original_ts, "url": cloud_url}) + else: + logger.warning(f"Job {job_id}: Could not parse timestamp from filename {scene_file}") + except Exception as parse_err: + logger.error(f"Job {job_id}: Error parsing timestamp from {scene_file}: {parse_err}") + except Exception as upload_err: + logger.error(f"Job {job_id}: Failed to upload scene {scene_file}. Error: {upload_err}") + # Optionally collect failed uploads info? For now, just log. + + logger.info(f"Job {job_id}: Scene extraction and upload complete.") + return scene_urls + + except Exception as e: + logger.error(f"Job {job_id}: Error in process_get_scenes: {str(e)}", exc_info=True) + raise # Re-raise the exception to be caught by the route handler + + finally: + # --- 4. Cleanup --- + logger.info(f"Job {job_id}: Cleaning up temporary files...") + if local_video_path and os.path.exists(local_video_path): + try: + os.remove(local_video_path) + logger.debug(f"Job {job_id}: Removed video file {local_video_path}") + except OSError as e: + logger.error(f"Job {job_id}: Error removing video file {local_video_path}: {e}") + for scene_file in generated_files: + if os.path.exists(scene_file): + try: + os.remove(scene_file) + logger.debug(f"Job {job_id}: Removed scene file {scene_file}") + except OSError as e: + logger.error(f"Job {job_id}: Error removing scene file {scene_file}: {e}")