Upload 6 files
Browse files- workflows/n8n/README.md +227 -0
- workflows/n8n/ai_provider_smoke_test.json +57 -0
- workflows/n8n/autonomous_tiktok_storytelling_miniseries.json +193 -0
- workflows/n8n/islamic_motivation_tiktok_pipeline.json +210 -0
- workflows/n8n/tiktok_storytelling_pexels.json +159 -0
- workflows/n8n/webhook_tiktok_storytelling_pexels.json +160 -0
workflows/n8n/README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# n8n Workflows
|
| 2 |
+
|
| 3 |
+
## Docker Compose n8n
|
| 4 |
+
|
| 5 |
+
The project Docker Compose stack includes n8n at:
|
| 6 |
+
|
| 7 |
+
```text
|
| 8 |
+
http://localhost:5678
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
Copy `.env.example` to `.env`, then set at least:
|
| 12 |
+
|
| 13 |
+
```env
|
| 14 |
+
MAESTER_API_KEY=your_maester_api_key
|
| 15 |
+
N8N_ENCRYPTION_KEY=generate_a_long_stable_random_value
|
| 16 |
+
PEXELS_API_KEY=your_pexels_api_key
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
The compose file injects these values into n8n:
|
| 20 |
+
|
| 21 |
+
```env
|
| 22 |
+
MAESTER_BASE_URL=http://maester-enterprise:7860
|
| 23 |
+
MAESTER_API_KEY=your_maester_api_key
|
| 24 |
+
OPENAI_API_KEY=your_openai_api_key
|
| 25 |
+
GEMINI_API_KEY=your_gemini_api_key
|
| 26 |
+
OPENROUTER_API_KEY=your_openrouter_api_key
|
| 27 |
+
AI_PROVIDER=openai
|
| 28 |
+
LLM_PROVIDER=openai
|
| 29 |
+
PEXELS_API_KEY=your_pexels_api_key
|
| 30 |
+
LLM_MODEL=gpt-4o-mini
|
| 31 |
+
KTTS_MODEL=kokoro-v0_19.onnx
|
| 32 |
+
KTTS_VOICE=af_bella.pt
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Import any workflow JSON from this folder into n8n after the stack is running.
|
| 36 |
+
|
| 37 |
+
## AI Provider Smoke Test
|
| 38 |
+
|
| 39 |
+
Import:
|
| 40 |
+
|
| 41 |
+
```text
|
| 42 |
+
ai_provider_smoke_test.json
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Set `AI_PROVIDER` or `LLM_PROVIDER` in `.env` to one of:
|
| 46 |
+
|
| 47 |
+
```text
|
| 48 |
+
openai
|
| 49 |
+
gemini
|
| 50 |
+
openrouter
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
The workflow uses these provider-specific variables:
|
| 54 |
+
|
| 55 |
+
```env
|
| 56 |
+
OPENAI_API_KEY=
|
| 57 |
+
OPENAI_BASE_URL=https://api.openai.com/v1
|
| 58 |
+
OPENAI_MODEL=gpt-4o-mini
|
| 59 |
+
|
| 60 |
+
GEMINI_API_KEY=
|
| 61 |
+
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
| 62 |
+
GEMINI_MODEL=gemini-1.5-flash
|
| 63 |
+
|
| 64 |
+
OPENROUTER_API_KEY=
|
| 65 |
+
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
| 66 |
+
OPENROUTER_MODEL=openai/gpt-4o-mini
|
| 67 |
+
OPENROUTER_SITE_URL=http://localhost:5678
|
| 68 |
+
OPENROUTER_APP_NAME=Maester Enterprise
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
OpenRouter uses the OpenAI-compatible chat completions format. Gemini uses the `generateContent` endpoint and returns a Gemini-shaped response.
|
| 72 |
+
|
| 73 |
+
## Webhook TikTok Storytelling With Pexels
|
| 74 |
+
|
| 75 |
+
Import:
|
| 76 |
+
|
| 77 |
+
```text
|
| 78 |
+
webhook_tiktok_storytelling_pexels.json
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
This workflow exposes an n8n webhook at:
|
| 82 |
+
|
| 83 |
+
```text
|
| 84 |
+
POST /webhook/maester/create-tiktok-story
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Example payload:
|
| 88 |
+
|
| 89 |
+
```json
|
| 90 |
+
{
|
| 91 |
+
"topic": "a founder saves 10 hours a week with automation",
|
| 92 |
+
"series_title": "Automation Proof",
|
| 93 |
+
"audience": "small business owners",
|
| 94 |
+
"scene_count": 5,
|
| 95 |
+
"scene_duration": 5,
|
| 96 |
+
"output_name": "automation_proof_episode_01.mp4"
|
| 97 |
+
}
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
The webhook creates a TikTok render payload, searches Pexels for portrait clips, submits the job to `/services/render/render`, and returns Maester's `job_id`, `status_url`, and `download_url`. Poll `MAESTER_BASE_URL + status_url` until the job state is complete, then download from `MAESTER_BASE_URL + download_url`.
|
| 101 |
+
|
| 102 |
+
## TikTok Storytelling Mini Series With Pexels
|
| 103 |
+
|
| 104 |
+
Import `tiktok_storytelling_pexels.json` into n8n.
|
| 105 |
+
|
| 106 |
+
For the full autonomous version from the project specification, import:
|
| 107 |
+
|
| 108 |
+
```text
|
| 109 |
+
autonomous_tiktok_storytelling_miniseries.json
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
Required n8n environment variables:
|
| 113 |
+
|
| 114 |
+
```env
|
| 115 |
+
MAESTER_BASE_URL=https://your-maester-space.hf.space
|
| 116 |
+
MAESTER_API_KEY=your_maester_api_key
|
| 117 |
+
PEXELS_API_KEY=your_pexels_api_key
|
| 118 |
+
OPENAI_API_KEY=your_openai_api_key
|
| 119 |
+
LLM_MODEL=gpt-4o-mini
|
| 120 |
+
KTTS_MODEL=kokoro-v0_19.onnx
|
| 121 |
+
KTTS_VOICE=af_bella.pt
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
What the workflow does:
|
| 125 |
+
|
| 126 |
+
1. Builds a five-scene TikTok storytelling episode.
|
| 127 |
+
2. Searches Pexels videos for each scene query.
|
| 128 |
+
3. Picks portrait MP4 assets when available.
|
| 129 |
+
4. Creates a Maester render payload with scene captions, text overlays, blur background, and per-scene effects.
|
| 130 |
+
5. Submits the render job to `/services/render/render`.
|
| 131 |
+
|
| 132 |
+
The workflow expects all services to be enabled in Maester:
|
| 133 |
+
|
| 134 |
+
```env
|
| 135 |
+
MAESTER_MOUNT_SERVICES=true
|
| 136 |
+
MAESTER_ENABLE_RENDER_ENGINE=true
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
The final node returns the render job response. Add a `Wait` node and an HTTP Request node against the returned `status_url` if you want automatic polling.
|
| 140 |
+
|
| 141 |
+
Pexels attribution metadata is included in each scene under `metadata.pexels` so you can preserve attribution data in downstream reports.
|
| 142 |
+
|
| 143 |
+
## Autonomous Mini-Series Workflow
|
| 144 |
+
|
| 145 |
+
`autonomous_tiktok_storytelling_miniseries.json` implements this production pipeline:
|
| 146 |
+
|
| 147 |
+
1. Configures the topic: `The Girl Who Disappeared Every Midnight`.
|
| 148 |
+
2. Calls OpenAI Chat Completions and requests strict JSON story output.
|
| 149 |
+
3. Validates the returned story schema.
|
| 150 |
+
4. Creates a Maester campaign record through `/automation/campaigns`.
|
| 151 |
+
5. Runs Maester quality and compliance checks.
|
| 152 |
+
6. Loops through scenes inside an n8n Code node.
|
| 153 |
+
7. Searches Pexels portrait videos with retry queries.
|
| 154 |
+
8. Generates KTTS narration for every scene through `/services/ktts/v1/audio/speech`.
|
| 155 |
+
9. Uploads scene narration files to `/services/render/upload`.
|
| 156 |
+
10. Generates and uploads a full episode narration track for the final render engine voiceover.
|
| 157 |
+
11. Builds a 1080x1920 TikTok render payload with captions, blur background, scene effects, animated subtitle settings, and Pexels attribution metadata.
|
| 158 |
+
12. Submits the final render to `/services/render/render`.
|
| 159 |
+
|
| 160 |
+
The render engine accepts one global `voiceover` track, so the workflow generates per-scene narration artifacts for traceability and also creates one combined narration track for final video rendering.
|
| 161 |
+
|
| 162 |
+
## Islamic Motivation TikTok Automation Pipeline
|
| 163 |
+
|
| 164 |
+
Import:
|
| 165 |
+
|
| 166 |
+
```text
|
| 167 |
+
islamic_motivation_tiktok_pipeline.json
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
This workflow implements the Islamic motivation pipeline:
|
| 171 |
+
|
| 172 |
+
1. Runs once per day from an n8n Schedule Trigger.
|
| 173 |
+
2. Calls GPT-4o for a respectful Islamic motivational topic and narration.
|
| 174 |
+
3. Calls GPT-4o again for exactly five cinematic Pexels-optimized scenes.
|
| 175 |
+
4. Searches Pexels portrait videos scene by scene, with `nature` fallback.
|
| 176 |
+
5. Downloads each clip and sends it to Maester FFmpeg automation.
|
| 177 |
+
6. Trims and resizes each clip to 1080x1920 / 9:16.
|
| 178 |
+
7. Concats clips in deterministic scene order.
|
| 179 |
+
8. Searches Archive.org for an Islamic nasheed and continues without music if none is found.
|
| 180 |
+
9. Burns TikTok-style ASS captions into the video.
|
| 181 |
+
10. Merges nasheed audio at low volume.
|
| 182 |
+
11. Normalizes the final MP4 for TikTok compatibility.
|
| 183 |
+
12. Uploads the final file to Maester render inspection and validates duration, size, audio, and video stream metadata.
|
| 184 |
+
13. Uploads to TikTok if `TIKTOK_UPLOAD_ENABLED=true`; otherwise returns a dry-run render result.
|
| 185 |
+
14. Sends Google Sheets and Telegram status updates.
|
| 186 |
+
|
| 187 |
+
Required environment variables:
|
| 188 |
+
|
| 189 |
+
```env
|
| 190 |
+
MAESTER_BASE_URL=https://your-maester-space.hf.space
|
| 191 |
+
MAESTER_API_KEY=your_maester_api_key
|
| 192 |
+
OPENAI_API_KEY=your_openai_api_key
|
| 193 |
+
PEXELS_API_KEY=your_pexels_api_key
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
Optional publishing/logging variables:
|
| 197 |
+
|
| 198 |
+
```env
|
| 199 |
+
TELEGRAM_BOT_TOKEN=123456:telegram_bot_token
|
| 200 |
+
TELEGRAM_CHAT_ID=123456789
|
| 201 |
+
GOOGLE_SHEETS_WEBHOOK_URL=https://script.google.com/macros/s/your-script-id/exec
|
| 202 |
+
TIKTOK_UPLOAD_ENABLED=false
|
| 203 |
+
TIKTOK_ACCESS_TOKEN=your_tiktok_content_posting_access_token
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
Required Maester services:
|
| 207 |
+
|
| 208 |
+
```env
|
| 209 |
+
MAESTER_MOUNT_SERVICES=true
|
| 210 |
+
MAESTER_ENABLE_FFMPEG_AUTOMATION=true
|
| 211 |
+
MAESTER_ENABLE_RENDER_ENGINE=true
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
The workflow uses these Maester endpoints:
|
| 215 |
+
|
| 216 |
+
- `/automation/campaigns`
|
| 217 |
+
- `/automation/compliance/check`
|
| 218 |
+
- `/services/ffmpeg/n8n/execute/trim`
|
| 219 |
+
- `/services/ffmpeg/n8n/execute/resize_916`
|
| 220 |
+
- `/services/ffmpeg/n8n/execute/concat`
|
| 221 |
+
- `/services/ffmpeg/n8n/execute/add_subtitles`
|
| 222 |
+
- `/services/ffmpeg/n8n/execute/merge_music`
|
| 223 |
+
- `/services/ffmpeg/n8n/execute/normalize`
|
| 224 |
+
- `/services/render/upload`
|
| 225 |
+
- `/services/render/inspect`
|
| 226 |
+
|
| 227 |
+
TikTok upload is intentionally dry-run by default. Enable it only after your TikTok Content Posting API app is approved and tested.
|
workflows/n8n/ai_provider_smoke_test.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Maester - AI Provider Smoke Test",
|
| 3 |
+
"nodes": [
|
| 4 |
+
{
|
| 5 |
+
"parameters": {},
|
| 6 |
+
"id": "manual-trigger",
|
| 7 |
+
"name": "Manual Trigger",
|
| 8 |
+
"type": "n8n-nodes-base.manualTrigger",
|
| 9 |
+
"typeVersion": 1,
|
| 10 |
+
"position": [
|
| 11 |
+
0,
|
| 12 |
+
0
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"parameters": {
|
| 17 |
+
"jsCode": "const provider = ($env.LLM_PROVIDER || $env.AI_PROVIDER || 'openai').toLowerCase();\nconst prompt = 'Return one short sentence confirming the Maester AI provider is working.';\n\nasync function callOpenAiCompatible({ apiKey, baseUrl, model, extraHeaders = {} }) {\n if (!apiKey) throw new Error(`Missing API key for ${provider}.`);\n return await this.helpers.httpRequest({\n method: 'POST',\n url: `${baseUrl.replace(/\\/$/, '')}/chat/completions`,\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n ...extraHeaders\n },\n body: JSON.stringify({\n model,\n messages: [\n { role: 'system', content: 'You are a concise operational test assistant.' },\n { role: 'user', content: prompt }\n ]\n }),\n json: true,\n timeout: 60000\n });\n}\n\nlet response;\nif (provider === 'openai') {\n response = await callOpenAiCompatible.call(this, {\n apiKey: $env.OPENAI_API_KEY,\n baseUrl: $env.OPENAI_BASE_URL || 'https://api.openai.com/v1',\n model: $env.OPENAI_MODEL || $env.LLM_MODEL || 'gpt-4o-mini'\n });\n} else if (provider === 'openrouter') {\n response = await callOpenAiCompatible.call(this, {\n apiKey: $env.OPENROUTER_API_KEY,\n baseUrl: $env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',\n model: $env.OPENROUTER_MODEL || $env.LLM_MODEL || 'openai/gpt-4o-mini',\n extraHeaders: {\n 'HTTP-Referer': $env.OPENROUTER_SITE_URL || 'http://localhost:5678',\n 'X-Title': $env.OPENROUTER_APP_NAME || 'Maester Enterprise'\n }\n });\n} else if (provider === 'gemini') {\n const apiKey = $env.GEMINI_API_KEY;\n if (!apiKey) throw new Error('Missing GEMINI_API_KEY.');\n const baseUrl = ($env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta').replace(/\\/$/, '');\n const model = $env.GEMINI_MODEL || $env.LLM_MODEL || 'gemini-1.5-flash';\n response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${baseUrl}/models/${model}:generateContent?key=${apiKey}`,\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [\n {\n role: 'user',\n parts: [{ text: prompt }]\n }\n ]\n }),\n json: true,\n timeout: 60000\n });\n} else {\n throw new Error(`Unsupported AI provider: ${provider}`);\n}\n\nreturn [{ json: { ok: true, provider, response } }];"
|
| 18 |
+
},
|
| 19 |
+
"id": "call-provider",
|
| 20 |
+
"name": "Call Selected Provider",
|
| 21 |
+
"type": "n8n-nodes-base.code",
|
| 22 |
+
"typeVersion": 2,
|
| 23 |
+
"position": [
|
| 24 |
+
260,
|
| 25 |
+
0
|
| 26 |
+
]
|
| 27 |
+
}
|
| 28 |
+
],
|
| 29 |
+
"connections": {
|
| 30 |
+
"Manual Trigger": {
|
| 31 |
+
"main": [
|
| 32 |
+
[
|
| 33 |
+
{
|
| 34 |
+
"node": "Call Selected Provider",
|
| 35 |
+
"type": "main",
|
| 36 |
+
"index": 0
|
| 37 |
+
}
|
| 38 |
+
]
|
| 39 |
+
]
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"pinData": {},
|
| 43 |
+
"settings": {
|
| 44 |
+
"executionOrder": "v1"
|
| 45 |
+
},
|
| 46 |
+
"staticData": null,
|
| 47 |
+
"tags": [
|
| 48 |
+
{
|
| 49 |
+
"name": "maester"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"name": "ai-provider"
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"triggerCount": 0,
|
| 56 |
+
"versionId": "maester-ai-provider-smoke-test-v1"
|
| 57 |
+
}
|
workflows/n8n/autonomous_tiktok_storytelling_miniseries.json
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Maester - Autonomous TikTok Storytelling Mini-Series Generator",
|
| 3 |
+
"nodes": [
|
| 4 |
+
{
|
| 5 |
+
"parameters": {},
|
| 6 |
+
"id": "manual-trigger",
|
| 7 |
+
"name": "Manual Trigger",
|
| 8 |
+
"type": "n8n-nodes-base.manualTrigger",
|
| 9 |
+
"typeVersion": 1,
|
| 10 |
+
"position": [0, 0]
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"parameters": {
|
| 14 |
+
"jsCode": "return [{\n json: {\n topic: 'The Girl Who Disappeared Every Midnight',\n maesterBaseUrl: $env.MAESTER_BASE_URL || 'http://localhost:7860',\n maesterApiKey: $env.MAESTER_API_KEY || 'replace_with_maester_api_key',\n pexelsApiKey: $env.PEXELS_API_KEY || 'replace_with_pexels_api_key',\n openAiApiKey: $env.OPENAI_API_KEY || 'replace_with_openai_api_key',\n llmModel: $env.LLM_MODEL || 'gpt-4o-mini',\n kttsModel: $env.KTTS_MODEL || 'kokoro-v0_19.onnx',\n kttsVoice: $env.KTTS_VOICE || 'af_bella.pt',\n sceneCount: 6,\n defaultSceneDuration: 6,\n platform: 'tiktok',\n template: 'tiktok_classic',\n creativeStyle: 'cinematic_story',\n outputName: 'the_girl_who_disappeared_every_midnight_ep01.mp4',\n targetResolution: '1080x1920',\n backgroundEffect: 'blur',\n cinematicEffects: ['slow_zoom', 'film_grain', 'vignette', 'push_in', 'light_leak', 'caption_pop'],\n captionStyle: 'animated_ass',\n musicMoodFallback: 'dark emotional piano',\n voiceSpeed: 0.95\n }\n}];"
|
| 15 |
+
},
|
| 16 |
+
"id": "configure-pipeline",
|
| 17 |
+
"name": "Configure Pipeline",
|
| 18 |
+
"type": "n8n-nodes-base.code",
|
| 19 |
+
"typeVersion": 2,
|
| 20 |
+
"position": [240, 0]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"parameters": {
|
| 24 |
+
"method": "POST",
|
| 25 |
+
"url": "https://api.openai.com/v1/chat/completions",
|
| 26 |
+
"sendHeaders": true,
|
| 27 |
+
"headerParameters": {
|
| 28 |
+
"parameters": [
|
| 29 |
+
{
|
| 30 |
+
"name": "Authorization",
|
| 31 |
+
"value": "={{'Bearer ' + $json.openAiApiKey}}"
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"name": "Content-Type",
|
| 35 |
+
"value": "application/json"
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
"sendBody": true,
|
| 40 |
+
"contentType": "json",
|
| 41 |
+
"jsonBody": "={{JSON.stringify({ model: $json.llmModel, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: 'You are a short-form TikTok storytelling writer. Return strict JSON only. No markdown. No extra text.' }, { role: 'user', content: `Create episode 1 of a cinematic TikTok storytelling mini-series from this topic: ${$json.topic}. The story must be suspenseful but platform-safe. Return exactly this JSON schema: {\"title\":\"string\",\"description\":\"string\",\"hashtags\":[\"#tag\"],\"music_mood\":\"string\",\"scenes\":[{\"scene\":1,\"duration\":6,\"search_query\":\"portrait cinematic stock video query\",\"caption\":\"short subtitle overlay\",\"tts_script\":\"narration sentence\"}]}. Requirements: ${$json.sceneCount} scenes, each ${$json.defaultSceneDuration} seconds unless story pacing requires a small adjustment. Pexels search queries must be visual, cinematic, portrait-friendly, and specific. Captions must be short enough for TikTok overlays. TTS scripts should join into one coherent narration.` } ] })}}",
|
| 42 |
+
"options": {
|
| 43 |
+
"timeout": 120000
|
| 44 |
+
}
|
| 45 |
+
},
|
| 46 |
+
"id": "generate-story-json",
|
| 47 |
+
"name": "Generate Story JSON",
|
| 48 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 49 |
+
"typeVersion": 4.2,
|
| 50 |
+
"position": [520, 0]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"parameters": {
|
| 54 |
+
"jsCode": "const config = $('Configure Pipeline').first().json;\nconst response = items[0].json;\nconst raw = response.choices?.[0]?.message?.content;\nif (!raw) throw new Error('LLM response did not include choices[0].message.content');\nlet story;\ntry {\n story = JSON.parse(raw);\n} catch (error) {\n throw new Error(`LLM did not return valid JSON: ${error.message}`);\n}\nif (!Array.isArray(story.scenes) || !story.scenes.length) {\n throw new Error('Story JSON must include a non-empty scenes array.');\n}\nstory.scenes = story.scenes.map((scene, index) => ({\n scene: Number(scene.scene || index + 1),\n duration: Number(scene.duration || config.defaultSceneDuration),\n search_query: String(scene.search_query || `${config.topic} cinematic night vertical video`),\n caption: String(scene.caption || '').slice(0, 120),\n tts_script: String(scene.tts_script || scene.caption || '').trim()\n}));\nreturn [{ json: { ...config, story } }];"
|
| 55 |
+
},
|
| 56 |
+
"id": "validate-story-json",
|
| 57 |
+
"name": "Validate Story JSON",
|
| 58 |
+
"type": "n8n-nodes-base.code",
|
| 59 |
+
"typeVersion": 2,
|
| 60 |
+
"position": [800, 0]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"parameters": {
|
| 64 |
+
"method": "POST",
|
| 65 |
+
"url": "={{$json.maesterBaseUrl + '/automation/campaigns'}}",
|
| 66 |
+
"sendHeaders": true,
|
| 67 |
+
"headerParameters": {
|
| 68 |
+
"parameters": [
|
| 69 |
+
{
|
| 70 |
+
"name": "X-API-Key",
|
| 71 |
+
"value": "={{$json.maesterApiKey}}"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"name": "Content-Type",
|
| 75 |
+
"value": "application/json"
|
| 76 |
+
}
|
| 77 |
+
]
|
| 78 |
+
},
|
| 79 |
+
"sendBody": true,
|
| 80 |
+
"contentType": "json",
|
| 81 |
+
"jsonBody": "={{JSON.stringify({ name: $json.story.title, topic: $json.topic, niche: 'fiction storytelling', platforms: ['tiktok'], quantity: $json.story.scenes.length, tone: 'cinematic suspense storytelling', metadata: { source: 'n8n_autonomous_storytelling', description: $json.story.description, hashtags: $json.story.hashtags, music_mood: $json.story.music_mood } })}}",
|
| 82 |
+
"options": {
|
| 83 |
+
"timeout": 60000
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
"id": "create-maester-campaign",
|
| 87 |
+
"name": "Create Maester Campaign",
|
| 88 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 89 |
+
"typeVersion": 4.2,
|
| 90 |
+
"position": [1080, 0]
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"parameters": {
|
| 94 |
+
"jsCode": "const config = $('Validate Story JSON').first().json;\nconst campaign = items[0].json;\nreturn [{ json: { ...config, campaign } }];"
|
| 95 |
+
},
|
| 96 |
+
"id": "attach-campaign",
|
| 97 |
+
"name": "Attach Campaign",
|
| 98 |
+
"type": "n8n-nodes-base.code",
|
| 99 |
+
"typeVersion": 2,
|
| 100 |
+
"position": [1320, 0]
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"parameters": {
|
| 104 |
+
"jsCode": "const input = items[0].json;\nconst base = input.maesterBaseUrl.replace(/\\/$/, '');\nconst headers = { 'X-API-Key': input.maesterApiKey, 'Content-Type': 'application/json' };\n\nfunction requireSecret(name, value) {\n if (!value || value.startsWith('replace_with_')) throw new Error(`Set ${name} in n8n environment variables.`);\n}\nrequireSecret('MAESTER_API_KEY', input.maesterApiKey);\nrequireSecret('PEXELS_API_KEY', input.pexelsApiKey);\n\nasync function maesterJson(path, body) {\n return await this.helpers.httpRequest({ method: 'POST', url: `${base}${path}`, headers, body: JSON.stringify(body), json: true, timeout: 120000 });\n}\n\nasync function searchPexels(scene) {\n const queries = [scene.search_query, scene.search_query.replace(/ at night| rainy| dark/gi, '').trim(), `${input.topic} cinematic vertical story`];\n let lastError;\n for (const query of queries) {\n try {\n const result = await this.helpers.httpRequest({\n method: 'GET',\n url: 'https://api.pexels.com/videos/search',\n headers: { Authorization: input.pexelsApiKey },\n qs: { query, orientation: 'portrait', size: 'medium', per_page: 10 },\n json: true,\n timeout: 90000\n });\n const candidates = (result.videos || [])\n .filter(video => Number(video.duration || 0) >= Number(scene.duration || input.defaultSceneDuration))\n .map(video => {\n const files = (video.video_files || []).filter(file => file.file_type === 'video/mp4' && file.link);\n const best = files.sort((a, b) => {\n const aPortrait = (a.height || 0) > (a.width || 0) ? 1 : 0;\n const bPortrait = (b.height || 0) > (b.width || 0) ? 1 : 0;\n return (bPortrait * 100000000 + (b.height || 0) * (b.width || 0)) - (aPortrait * 100000000 + (a.height || 0) * (a.width || 0));\n })[0];\n return best ? { video, file: best, query } : null;\n })\n .filter(Boolean);\n if (candidates.length) {\n const picked = candidates[0];\n return { media: picked.file.link, pexels: { id: picked.video.id, url: picked.video.url, user: picked.video.user?.name, query, duration: picked.video.duration, width: picked.file.width, height: picked.file.height } };\n }\n } catch (error) {\n lastError = error;\n }\n }\n throw new Error(`No suitable Pexels portrait video for scene ${scene.scene}. ${lastError?.message || ''}`);\n}\n\nasync function generateTts(text, filename) {\n const audio = await this.helpers.httpRequest({\n method: 'POST',\n url: `${base}/services/ktts/v1/audio/speech`,\n headers,\n body: JSON.stringify({ input: text, model: input.kttsModel, voice: input.kttsVoice, speed: input.voiceSpeed }),\n json: false,\n encoding: null,\n timeout: 240000\n });\n return { filename, audio };\n}\n\nasync function uploadAudio(audio, filename) {\n const upload = await this.helpers.httpRequest({\n method: 'POST',\n url: `${base}/services/render/upload`,\n headers: { 'X-API-Key': input.maesterApiKey },\n formData: { files: { value: audio, options: { filename, contentType: 'audio/wav' } } },\n json: true,\n timeout: 240000\n });\n const asset = upload.assets?.[0];\n if (!asset?.path) throw new Error(`Audio upload failed for ${filename}`);\n return asset.path;\n}\n\nconst quality = await maesterJson('/automation/quality/hook', { hook: input.story.scenes[0].caption });\nconst compliance = await maesterJson('/automation/compliance/check', { platform: 'tiktok', title: input.story.title, caption: input.story.description, hashtags: input.story.hashtags || [], duration_seconds: input.story.scenes.reduce((sum, scene) => sum + Number(scene.duration || input.defaultSceneDuration), 0), has_disclosure: false });\n\nconst resolvedScenes = [];\nconst sceneAudio = [];\nfor (const scene of input.story.scenes) {\n const stock = await searchPexels.call(this, scene);\n const sceneTts = await generateTts.call(this, scene.tts_script, `scene_${scene.scene}_narration.wav`);\n const sceneAudioPath = await uploadAudio.call(this, sceneTts.audio, sceneTts.filename);\n sceneAudio.push({ scene: scene.scene, audio_path: sceneAudioPath, tts_script: scene.tts_script });\n const effect = input.cinematicEffects[(scene.scene - 1) % input.cinematicEffects.length];\n resolvedScenes.push({\n start: resolvedScenes.reduce((sum, item) => sum + Number(item.duration || 0), 0),\n duration: Number(scene.duration || input.defaultSceneDuration),\n media: stock.media,\n caption: scene.caption,\n transition: scene.scene === 1 ? 'fade' : 'smooth',\n background: input.backgroundEffect,\n layout: 'fill',\n effect,\n metadata: { pexels: stock.pexels, scene_audio_path: sceneAudioPath, tts_script: scene.tts_script }\n });\n}\n\nconst fullNarration = input.story.scenes.map(scene => scene.tts_script).join('\\n');\nconst fullTts = await generateTts.call(this, fullNarration, 'full_episode_narration.wav');\nconst fullVoiceoverPath = await uploadAudio.call(this, fullTts.audio, fullTts.filename);\n\nconst renderPayload = {\n template: input.template,\n creative_style: input.creativeStyle,\n platform: input.platform,\n preset: 'tiktok_9_16_fast',\n output_name: input.outputName,\n voiceover: fullVoiceoverPath,\n music_volume: 0.24,\n music_ducking: true,\n voice_volume: 1.0,\n subtitle_format: 'ass',\n auto_subtitles: true,\n subtitle_language: 'en',\n audio_normalize: true,\n normalize: true,\n metadata: { title: input.story.title, description: input.story.description, hashtags: input.story.hashtags, music_mood: input.story.music_mood || input.musicMoodFallback, campaign_id: input.campaign.id, scene_audio, quality, compliance },\n scenes: resolvedScenes\n};\n\nreturn [{ json: { ...input, quality, compliance, resolvedScenes, sceneAudio, fullVoiceoverPath, renderPayload } }];"
|
| 105 |
+
},
|
| 106 |
+
"id": "process-scenes",
|
| 107 |
+
"name": "Process Scenes: Pexels + TTS + Upload",
|
| 108 |
+
"type": "n8n-nodes-base.code",
|
| 109 |
+
"typeVersion": 2,
|
| 110 |
+
"position": [1600, 0]
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"parameters": {
|
| 114 |
+
"method": "POST",
|
| 115 |
+
"url": "={{$json.maesterBaseUrl + '/services/render/render'}}",
|
| 116 |
+
"sendHeaders": true,
|
| 117 |
+
"headerParameters": {
|
| 118 |
+
"parameters": [
|
| 119 |
+
{
|
| 120 |
+
"name": "X-API-Key",
|
| 121 |
+
"value": "={{$json.maesterApiKey}}"
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"name": "Content-Type",
|
| 125 |
+
"value": "application/json"
|
| 126 |
+
}
|
| 127 |
+
]
|
| 128 |
+
},
|
| 129 |
+
"sendBody": true,
|
| 130 |
+
"contentType": "json",
|
| 131 |
+
"jsonBody": "={{JSON.stringify($json.renderPayload)}}",
|
| 132 |
+
"options": {
|
| 133 |
+
"timeout": 180000
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
"id": "submit-final-render",
|
| 137 |
+
"name": "Submit Final TikTok Render",
|
| 138 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 139 |
+
"typeVersion": 4.2,
|
| 140 |
+
"position": [1880, 0]
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"parameters": {
|
| 144 |
+
"jsCode": "const pipeline = $('Process Scenes: Pexels + TTS + Upload').first().json;\nconst render = items[0].json;\nreturn [{\n json: {\n status: 'render_submitted',\n title: pipeline.story.title,\n description: pipeline.story.description,\n hashtags: pipeline.story.hashtags,\n campaign_id: pipeline.campaign.id,\n render_job: render,\n status_url: render.status_url,\n download_url: render.download_url,\n quality: pipeline.quality,\n compliance: pipeline.compliance,\n scenes: pipeline.resolvedScenes.map(scene => ({ caption: scene.caption, media: scene.media, effect: scene.effect, pexels: scene.metadata.pexels })),\n scene_audio: pipeline.sceneAudio,\n note: 'Poll status_url until the render job is COMPLETED, then download the MP4 from download_url.'\n }\n}];"
|
| 145 |
+
},
|
| 146 |
+
"id": "return-job-details",
|
| 147 |
+
"name": "Return Job Details",
|
| 148 |
+
"type": "n8n-nodes-base.code",
|
| 149 |
+
"typeVersion": 2,
|
| 150 |
+
"position": [2160, 0]
|
| 151 |
+
}
|
| 152 |
+
],
|
| 153 |
+
"connections": {
|
| 154 |
+
"Manual Trigger": {
|
| 155 |
+
"main": [[{ "node": "Configure Pipeline", "type": "main", "index": 0 }]]
|
| 156 |
+
},
|
| 157 |
+
"Configure Pipeline": {
|
| 158 |
+
"main": [[{ "node": "Generate Story JSON", "type": "main", "index": 0 }]]
|
| 159 |
+
},
|
| 160 |
+
"Generate Story JSON": {
|
| 161 |
+
"main": [[{ "node": "Validate Story JSON", "type": "main", "index": 0 }]]
|
| 162 |
+
},
|
| 163 |
+
"Validate Story JSON": {
|
| 164 |
+
"main": [[{ "node": "Create Maester Campaign", "type": "main", "index": 0 }]]
|
| 165 |
+
},
|
| 166 |
+
"Create Maester Campaign": {
|
| 167 |
+
"main": [[{ "node": "Attach Campaign", "type": "main", "index": 0 }]]
|
| 168 |
+
},
|
| 169 |
+
"Attach Campaign": {
|
| 170 |
+
"main": [[{ "node": "Process Scenes: Pexels + TTS + Upload", "type": "main", "index": 0 }]]
|
| 171 |
+
},
|
| 172 |
+
"Process Scenes: Pexels + TTS + Upload": {
|
| 173 |
+
"main": [[{ "node": "Submit Final TikTok Render", "type": "main", "index": 0 }]]
|
| 174 |
+
},
|
| 175 |
+
"Submit Final TikTok Render": {
|
| 176 |
+
"main": [[{ "node": "Return Job Details", "type": "main", "index": 0 }]]
|
| 177 |
+
}
|
| 178 |
+
},
|
| 179 |
+
"pinData": {},
|
| 180 |
+
"settings": {
|
| 181 |
+
"executionOrder": "v1"
|
| 182 |
+
},
|
| 183 |
+
"staticData": null,
|
| 184 |
+
"tags": [
|
| 185 |
+
{ "name": "maester" },
|
| 186 |
+
{ "name": "tiktok" },
|
| 187 |
+
{ "name": "storytelling" },
|
| 188 |
+
{ "name": "pexels" },
|
| 189 |
+
{ "name": "tts" }
|
| 190 |
+
],
|
| 191 |
+
"triggerCount": 0,
|
| 192 |
+
"versionId": "maester-autonomous-tiktok-storytelling-v1"
|
| 193 |
+
}
|
workflows/n8n/islamic_motivation_tiktok_pipeline.json
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Maester - Islamic Motivation TikTok Automation Pipeline",
|
| 3 |
+
"nodes": [
|
| 4 |
+
{
|
| 5 |
+
"parameters": {
|
| 6 |
+
"rule": {
|
| 7 |
+
"interval": [
|
| 8 |
+
{
|
| 9 |
+
"field": "days",
|
| 10 |
+
"triggerAtHour": 7
|
| 11 |
+
}
|
| 12 |
+
]
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"id": "schedule-trigger",
|
| 16 |
+
"name": "Schedule Trigger - Daily",
|
| 17 |
+
"type": "n8n-nodes-base.scheduleTrigger",
|
| 18 |
+
"typeVersion": 1.2,
|
| 19 |
+
"position": [0, 0]
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"parameters": {
|
| 23 |
+
"jsCode": "return [{\n json: {\n maesterBaseUrl: $env.MAESTER_BASE_URL || 'http://localhost:7860',\n maesterApiKey: $env.MAESTER_API_KEY || 'replace_with_maester_api_key',\n openAiApiKey: $env.OPENAI_API_KEY || 'replace_with_openai_api_key',\n pexelsApiKey: $env.PEXELS_API_KEY || 'replace_with_pexels_api_key',\n llmModel: $env.LLM_MODEL || 'gpt-4o',\n telegramBotToken: $env.TELEGRAM_BOT_TOKEN || '',\n telegramChatId: $env.TELEGRAM_CHAT_ID || '',\n googleSheetsWebhookUrl: $env.GOOGLE_SHEETS_WEBHOOK_URL || '',\n tiktokUploadEnabled: ($env.TIKTOK_UPLOAD_ENABLED || 'false').toLowerCase() === 'true',\n tiktokAccessToken: $env.TIKTOK_ACCESS_TOKEN || '',\n pexelsFallbackQuery: 'nature sunrise mountains peaceful sky clouds',\n archiveQuery: 'Islamic nasheed vocals only royalty free',\n sceneCount: 5,\n sceneDuration: 5,\n width: 1080,\n height: 1920,\n fps: 30,\n nasheedVolume: 0.18,\n optionalWatermark: $env.VIDEO_WATERMARK || '',\n startedAt: new Date().toISOString(),\n configNote: 'Prompts, API keys, schedule, and upload flags are centralized here.'\n }\n}];"
|
| 24 |
+
},
|
| 25 |
+
"id": "central-config",
|
| 26 |
+
"name": "Central Config",
|
| 27 |
+
"type": "n8n-nodes-base.code",
|
| 28 |
+
"typeVersion": 2,
|
| 29 |
+
"position": [240, 0]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"parameters": {
|
| 33 |
+
"method": "POST",
|
| 34 |
+
"url": "https://api.openai.com/v1/chat/completions",
|
| 35 |
+
"sendHeaders": true,
|
| 36 |
+
"headerParameters": {
|
| 37 |
+
"parameters": [
|
| 38 |
+
{
|
| 39 |
+
"name": "Authorization",
|
| 40 |
+
"value": "={{'Bearer ' + $json.openAiApiKey}}"
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"name": "Content-Type",
|
| 44 |
+
"value": "application/json"
|
| 45 |
+
}
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
"sendBody": true,
|
| 49 |
+
"contentType": "json",
|
| 50 |
+
"jsonBody": "={{JSON.stringify({ model: $json.llmModel, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: 'You are an Islamic motivation content agent for short-form video. Return strict JSON only. Do not fabricate Quran or hadith. Quote Quran only when authentic and cite surah/ayah when used. Quote hadith only when confidence is high and identify the source. Keep respectful tone.' }, { role: 'user', content: `Generate one Islamic motivational TikTok topic for today. Keep narration under 60 seconds. Produce engaging hook and respectful language. Return exactly: {\"title\":\"string\",\"message\":\"narration under 60 seconds\",\"description\":\"short description\",\"hashtags\":[\"#tag\"],\"sceneCount\":${$json.sceneCount}}. Avoid sectarian claims, fabricated narrations, and sensationalism.` } ] })}}",
|
| 51 |
+
"options": {
|
| 52 |
+
"timeout": 120000
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
"id": "islamic-motivation-agent",
|
| 56 |
+
"name": "Islamic Motivation Agent - GPT-4o",
|
| 57 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 58 |
+
"typeVersion": 4.2,
|
| 59 |
+
"position": [520, 0],
|
| 60 |
+
"retryOnFail": true,
|
| 61 |
+
"maxTries": 2,
|
| 62 |
+
"waitBetweenTries": 3000,
|
| 63 |
+
"continueOnFail": true
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"parameters": {
|
| 67 |
+
"jsCode": "const config = $('Central Config').first().json;\nasync function notify(message) {\n if (!config.telegramBotToken || !config.telegramChatId) return;\n await this.helpers.httpRequest({ method: 'POST', url: `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`, body: { chat_id: config.telegramChatId, text: message }, json: true, timeout: 30000 }).catch(() => undefined);\n}\nconst response = items[0].json;\nif (response.error) {\n await notify(`❌ GPT topic generation failed after retry.\\n${response.error.message || JSON.stringify(response.error)}`);\n throw new Error('GPT topic generation failed');\n}\nconst raw = response.choices?.[0]?.message?.content;\nif (!raw) {\n await notify('❌ GPT topic generation returned no content.');\n throw new Error('Missing GPT topic content');\n}\nlet motivation;\ntry { motivation = JSON.parse(raw); } catch (error) {\n await notify(`❌ GPT topic JSON parse failed: ${error.message}`);\n throw error;\n}\nfor (const key of ['title', 'message', 'description', 'hashtags']) {\n if (motivation[key] === undefined) throw new Error(`Motivation JSON missing ${key}`);\n}\nmotivation.sceneCount = Number(motivation.sceneCount || config.sceneCount);\nmotivation.hashtags = Array.isArray(motivation.hashtags) ? motivation.hashtags : ['#islam', '#motivation'];\nreturn [{ json: { ...config, motivation } }];"
|
| 68 |
+
},
|
| 69 |
+
"id": "parse-agent-output",
|
| 70 |
+
"name": "Parse Agent Output",
|
| 71 |
+
"type": "n8n-nodes-base.code",
|
| 72 |
+
"typeVersion": 2,
|
| 73 |
+
"position": [800, 0]
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"parameters": {
|
| 77 |
+
"method": "POST",
|
| 78 |
+
"url": "https://api.openai.com/v1/chat/completions",
|
| 79 |
+
"sendHeaders": true,
|
| 80 |
+
"headerParameters": {
|
| 81 |
+
"parameters": [
|
| 82 |
+
{
|
| 83 |
+
"name": "Authorization",
|
| 84 |
+
"value": "={{'Bearer ' + $json.openAiApiKey}}"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"name": "Content-Type",
|
| 88 |
+
"value": "application/json"
|
| 89 |
+
}
|
| 90 |
+
]
|
| 91 |
+
},
|
| 92 |
+
"sendBody": true,
|
| 93 |
+
"contentType": "json",
|
| 94 |
+
"jsonBody": "={{JSON.stringify({ model: $json.llmModel, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: 'You generate cinematic stock-footage scene plans for respectful Islamic motivational videos. Return strict JSON only.' }, { role: 'user', content: `Create exactly ${$json.sceneCount} scenes from this narration: ${$json.motivation.message}\\n\\nReturn {\"scenes\":[{\"scene\":1,\"search_query\":\"\",\"caption\":\"\",\"duration\":5,\"visual_description\":\"\"}]}. Each search_query must be optimized for Pexels portrait stock videos. Avoid humans dancing, music visuals, uncovered women, nightlife, inappropriate visuals, or disrespectful religious imagery. Prefer nature, sunrise, mosque exterior, desert, rain, mountains, Quran pages, prayer silhouettes, sky, clouds, calm water, and peaceful landscapes. Captions must be TikTok-short and match narration. Duration should be ${$json.sceneDuration}.` } ] })}}",
|
| 95 |
+
"options": {
|
| 96 |
+
"timeout": 120000
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"id": "scene-generator",
|
| 100 |
+
"name": "Scene Generator - GPT-4o",
|
| 101 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 102 |
+
"typeVersion": 4.2,
|
| 103 |
+
"position": [1080, 0],
|
| 104 |
+
"retryOnFail": true,
|
| 105 |
+
"maxTries": 2,
|
| 106 |
+
"waitBetweenTries": 3000,
|
| 107 |
+
"continueOnFail": true
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"parameters": {
|
| 111 |
+
"jsCode": "const base = $('Parse Agent Output').first().json;\nasync function notify(message) {\n if (!base.telegramBotToken || !base.telegramChatId) return;\n await this.helpers.httpRequest({ method: 'POST', url: `https://api.telegram.org/bot${base.telegramBotToken}/sendMessage`, body: { chat_id: base.telegramChatId, text: message }, json: true, timeout: 30000 }).catch(() => undefined);\n}\nconst response = items[0].json;\nif (response.error) {\n await notify(`❌ Scene generation failed after retry.\\n${response.error.message || JSON.stringify(response.error)}`);\n throw new Error('Scene generation failed');\n}\nconst raw = response.choices?.[0]?.message?.content;\nif (!raw) throw new Error('Missing scene JSON content');\nlet parsed;\ntry { parsed = JSON.parse(raw); } catch (error) {\n await notify(`❌ Scene JSON parse failed: ${error.message}`);\n throw error;\n}\nlet scenes = Array.isArray(parsed.scenes) ? parsed.scenes : [];\nif (scenes.length !== base.sceneCount) throw new Error(`Expected ${base.sceneCount} scenes, got ${scenes.length}`);\nscenes = scenes\n .map((scene, index) => ({\n scene: Number(scene.scene || index + 1),\n search_query: String(scene.search_query || base.pexelsFallbackQuery),\n caption: String(scene.caption || '').slice(0, 120),\n duration: Number(scene.duration || base.sceneDuration),\n visual_description: String(scene.visual_description || scene.search_query || '')\n }))\n .sort((a, b) => a.scene - b.scene);\nreturn [{ json: { ...base, scenes } }];"
|
| 112 |
+
},
|
| 113 |
+
"id": "parse-scene-json",
|
| 114 |
+
"name": "Parse Scene JSON",
|
| 115 |
+
"type": "n8n-nodes-base.code",
|
| 116 |
+
"typeVersion": 2,
|
| 117 |
+
"position": [1360, 0]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"parameters": {
|
| 121 |
+
"jsCode": "const input = items[0].json;\nconst baseUrl = input.maesterBaseUrl.replace(/\\/$/, '');\nconst started = Date.now();\nconst maesterHeaders = { 'X-API-Key': input.maesterApiKey, 'Content-Type': 'application/json' };\n\nfunction requireValue(name, value) {\n if (!value || String(value).startsWith('replace_with_')) throw new Error(`Missing required environment variable: ${name}`);\n}\nfunction toBase64(data) { return Buffer.from(data).toString('base64'); }\nfunction fromBase64(data) { return Buffer.from(data, 'base64'); }\nfunction secondsToAss(seconds) {\n const h = Math.floor(seconds / 3600);\n const m = Math.floor((seconds % 3600) / 60);\n const s = Math.floor(seconds % 60);\n const cs = Math.floor((seconds - Math.floor(seconds)) * 100);\n return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;\n}\nfunction buildAss(scenes) {\n let body = '[Script Info]\\nScriptType: v4.00+\\nPlayResX: 1080\\nPlayResY: 1920\\nWrapStyle: 0\\nScaledBorderAndShadow: yes\\n\\n[V4+ Styles]\\nFormat: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\\nStyle: TikTok,DejaVu Sans,72,&H00FFFFFF,&H00FFFFFF,&H00000000,&H7F000000,1,0,0,0,100,100,0,0,1,5,3,5,80,80,120,1\\n\\n[Events]\\nFormat: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\\n';\n let cursor = 0;\n for (const scene of scenes) {\n const end = cursor + Number(scene.duration || input.sceneDuration);\n const text = String(scene.caption || '').replace(/[{}]/g, '').replace(/\\n/g, ' ');\n body += `Dialogue: 0,${secondsToAss(cursor)},${secondsToAss(end)},TikTok,,0,0,0,,${text}\\n`;\n cursor = end;\n }\n return body;\n}\nasync function notifyTelegram(message) {\n if (!input.telegramBotToken || !input.telegramChatId) return { skipped: true };\n return await this.helpers.httpRequest({ method: 'POST', url: `https://api.telegram.org/bot${input.telegramBotToken}/sendMessage`, body: { chat_id: input.telegramChatId, text: message }, json: true, timeout: 30000 }).catch(error => ({ error: error.message }));\n}\nasync function requestWithRetry(options, attempts = 3) {\n let lastError;\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try { return await this.helpers.httpRequest(options); } catch (error) { lastError = error; }\n }\n throw lastError;\n}\nasync function maesterJson(path, body) {\n return await requestWithRetry.call(this, { method: 'POST', url: `${baseUrl}${path}`, headers: maesterHeaders, body: JSON.stringify(body), json: true, timeout: 120000 }, 2);\n}\nasync function ffmpegTask(task, files, options = {}) {\n const payload = { ...options, files };\n return await requestWithRetry.call(this, { method: 'POST', url: `${baseUrl}/services/ffmpeg/n8n/execute/${task}`, headers: maesterHeaders, body: JSON.stringify(payload), json: false, encoding: null, timeout: 600000 }, 1);\n}\nasync function searchPexels(scene) {\n const queries = [scene.search_query, input.pexelsFallbackQuery];\n for (const query of queries) {\n const result = await requestWithRetry.call(this, { method: 'GET', url: 'https://api.pexels.com/videos/search', headers: { Authorization: input.pexelsApiKey }, qs: { query, orientation: 'portrait', size: 'medium', per_page: 10 }, json: true, timeout: 90000 }, 3).catch(() => ({ videos: [] }));\n const candidates = (result.videos || [])\n .filter(video => Number(video.duration || 0) >= Number(scene.duration || input.sceneDuration))\n .map(video => {\n const files = (video.video_files || []).filter(file => file.file_type === 'video/mp4' && file.link && (file.height || 0) >= 720);\n const best = files.sort((a, b) => {\n const ap = (a.height || 0) > (a.width || 0) ? 1 : 0;\n const bp = (b.height || 0) > (b.width || 0) ? 1 : 0;\n return (bp * 100000000 + (b.height || 0) * (b.width || 0)) - (ap * 100000000 + (a.height || 0) * (a.width || 0));\n })[0];\n return best ? { video, file: best, query } : null;\n })\n .filter(Boolean);\n if (candidates.length) return candidates[0];\n }\n throw new Error(`Missing Pexels result for scene ${scene.scene}`);\n}\nasync function archiveNasheed() {\n try {\n const search = await requestWithRetry.call(this, { method: 'GET', url: 'https://archive.org/advancedsearch.php', qs: { q: input.archiveQuery, fl: 'identifier,title', rows: 8, output: 'json' }, json: true, timeout: 90000 }, 2);\n for (const doc of search.response?.docs || []) {\n const meta = await requestWithRetry.call(this, { method: 'GET', url: `https://archive.org/metadata/${doc.identifier}`, json: true, timeout: 90000 }, 2).catch(() => null);\n const file = (meta?.files || []).find(item => /\\.(mp3|ogg|m4a)$/i.test(item.name || '') && !/sample|preview/i.test(item.name || ''));\n if (file) {\n const url = `https://archive.org/download/${doc.identifier}/${encodeURIComponent(file.name)}`;\n const audio = await requestWithRetry.call(this, { method: 'GET', url, json: false, encoding: null, timeout: 180000 }, 2);\n return { audio, filename: file.name, source_url: url, identifier: doc.identifier };\n }\n }\n } catch (error) {\n return { error: error.message };\n }\n return null;\n}\nasync function uploadForInspect(videoBuffer) {\n const upload = await requestWithRetry.call(this, { method: 'POST', url: `${baseUrl}/services/render/upload`, headers: { 'X-API-Key': input.maesterApiKey }, formData: { files: { value: videoBuffer, options: { filename: 'islamic_motivation_final.mp4', contentType: 'video/mp4' } } }, json: true, timeout: 240000 }, 2);\n const asset = upload.assets?.[0];\n if (!asset?.path) throw new Error('Final upload for validation failed.');\n const inspect = await requestWithRetry.call(this, { method: 'POST', url: `${baseUrl}/services/render/inspect`, headers: { 'X-API-Key': input.maesterApiKey }, qs: { path: asset.path }, json: true, timeout: 120000 }, 2);\n return { asset, inspect };\n}\nasync function uploadTikTok(videoBuffer, title, description, hashtags) {\n if (!input.tiktokUploadEnabled) return { skipped: true, status: 'dry_run' };\n if (!input.tiktokAccessToken) throw new Error('TIKTOK_UPLOAD_ENABLED=true but TIKTOK_ACCESS_TOKEN is missing.');\n const size = videoBuffer.length;\n const init = await requestWithRetry.call(this, { method: 'POST', url: 'https://open.tiktokapis.com/v2/post/publish/video/init/', headers: { Authorization: `Bearer ${input.tiktokAccessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ post_info: { title: `${title}\\n\\n${hashtags.join(' ')}`.slice(0, 2200), privacy_level: 'SELF_ONLY', disable_duet: false, disable_comment: false, disable_stitch: false }, source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: size, total_chunk_count: 1 } }), json: true, timeout: 120000 }, 2);\n const uploadUrl = init.data?.upload_url || init.upload_url;\n const publishId = init.data?.publish_id || init.publish_id;\n if (!uploadUrl) throw new Error(`TikTok init did not return upload_url: ${JSON.stringify(init)}`);\n await requestWithRetry.call(this, { method: 'PUT', url: uploadUrl, headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(size), 'Content-Range': `bytes 0-${size - 1}/${size}` }, body: videoBuffer, json: false, timeout: 600000 }, 2);\n return { status: 'uploaded', publish_id: publishId, init };\n}\ntry {\n requireValue('MAESTER_API_KEY', input.maesterApiKey);\n requireValue('OPENAI_API_KEY', input.openAiApiKey);\n requireValue('PEXELS_API_KEY', input.pexelsApiKey);\n const campaign = await maesterJson.call(this, '/automation/campaigns', { name: input.motivation.title, topic: input.motivation.message, niche: 'islamic motivation', platforms: ['tiktok'], quantity: input.sceneCount, tone: 'respectful Islamic motivation', metadata: { description: input.motivation.description, hashtags: input.motivation.hashtags } });\n const compliance = await maesterJson.call(this, '/automation/compliance/check', { platform: 'tiktok', title: input.motivation.title, caption: input.motivation.description, hashtags: input.motivation.hashtags, duration_seconds: input.scenes.reduce((sum, scene) => sum + Number(scene.duration || input.sceneDuration), 0), has_disclosure: false });\n const resizedClips = [];\n const pexelsSources = [];\n for (const scene of input.scenes) {\n const match = await searchPexels.call(this, scene);\n pexelsSources.push({ scene: scene.scene, query: match.query, pexels_id: match.video.id, url: match.video.url, user: match.video.user?.name });\n const rawVideo = await requestWithRetry.call(this, { method: 'GET', url: match.file.link, json: false, encoding: null, timeout: 180000 }, 3);\n const trimmed = await ffmpegTask.call(this, 'trim', [{ fileName: `scene_${scene.scene}.mp4`, mimeType: 'video/mp4', data: toBase64(rawVideo) }], { start_time: '0', end_time: String(scene.duration) });\n const resized = await ffmpegTask.call(this, 'resize_916', [{ fileName: `scene_${scene.scene}_trimmed.mp4`, mimeType: 'video/mp4', data: toBase64(trimmed) }], { aspect_ratio: '9:16' });\n resizedClips.push({ fileName: `scene_${scene.scene}_resized.mp4`, mimeType: 'video/mp4', data: toBase64(resized) });\n }\n const concatVideo = await ffmpegTask.call(this, 'concat', resizedClips, {});\n const ass = buildAss(input.scenes);\n const subtitled = await ffmpegTask.call(this, 'add_subtitles', [\n { fileName: 'concat.mp4', mimeType: 'video/mp4', data: toBase64(concatVideo) },\n { fileName: 'captions.ass', mimeType: 'text/plain', data: toBase64(Buffer.from(ass, 'utf8')) }\n ], {});\n let finalVideo = subtitled;\n const nasheed = await archiveNasheed.call(this);\n if (nasheed?.audio) {\n finalVideo = await ffmpegTask.call(this, 'merge_music', [\n { fileName: 'subtitled.mp4', mimeType: 'video/mp4', data: toBase64(subtitled) },\n { fileName: nasheed.filename || 'nasheed.mp3', mimeType: 'audio/mpeg', data: toBase64(nasheed.audio) }\n ], { volume: String(input.nasheedVolume) });\n }\n const normalized = await ffmpegTask.call(this, 'normalize', [{ fileName: 'final_precheck.mp4', mimeType: 'video/mp4', data: toBase64(finalVideo) }], { resolution: '1080p', crf: '23', preset: 'veryfast' });\n const validation = await uploadForInspect.call(this, normalized);\n const meta = validation.inspect || {};\n const sizeOk = normalized.length > 1024 * 1024;\n const durationOk = Number(meta.duration || 0) > 0;\n const audioOk = Boolean(meta.has_audio);\n const videoOk = Number(meta.width || 0) > 0 && Number(meta.height || 0) > 0;\n if (!sizeOk || !durationOk || !audioOk || !videoOk) throw new Error(`Validation failed: ${JSON.stringify({ sizeOk, durationOk, audioOk, videoOk, meta })}`);\n const tiktok = await uploadTikTok.call(this, normalized, input.motivation.title, input.motivation.description, input.motivation.hashtags);\n const renderTimeSeconds = Math.round((Date.now() - started) / 1000);\n return [{ json: { ok: true, status: tiktok.skipped ? 'rendered_not_published' : 'published', title: input.motivation.title, description: input.motivation.description, hashtags: input.motivation.hashtags, campaign_id: campaign.id, tiktok, tiktok_post_id: tiktok.publish_id || 'dry_run', duration: meta.duration, render_time_seconds: renderTimeSeconds, validation: { size_bytes: normalized.length, metadata: meta }, pexels_sources: pexelsSources, nasheed: nasheed?.source_url ? { source_url: nasheed.source_url, identifier: nasheed.identifier } : { skipped: true, reason: nasheed?.error || 'not_found' }, googleSheetsRow: { date: new Date().toISOString(), title: input.motivation.title, tiktok_post_id: tiktok.publish_id || 'dry_run', duration: meta.duration, render_time_seconds: renderTimeSeconds, status: tiktok.skipped ? 'rendered_not_published' : 'published' }, telegramMessage: `✅ Video ${tiktok.skipped ? 'Rendered' : 'Published'}\\n\\nTitle:\\n${input.motivation.title}\\n\\nTikTok ID:\\n${tiktok.publish_id || 'dry_run'}\\n\\nDuration:\\n${meta.duration}s` } }];\n} catch (error) {\n const message = `❌ Islamic Motivation Pipeline Failed\\n\\n${error.message}`;\n await notifyTelegram.call(this, message);\n return [{ json: { ok: false, status: 'failed', error: error.message, telegramMessage: message, googleSheetsRow: { date: new Date().toISOString(), title: input.motivation?.title || 'unknown', tiktok_post_id: '', duration: '', render_time_seconds: Math.round((Date.now() - started) / 1000), status: 'failed' } } }];\n}"
|
| 122 |
+
},
|
| 123 |
+
"id": "render-edit-publish",
|
| 124 |
+
"name": "Render/Edit/Publish with Maester",
|
| 125 |
+
"type": "n8n-nodes-base.code",
|
| 126 |
+
"typeVersion": 2,
|
| 127 |
+
"position": [1640, 0]
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"parameters": {
|
| 131 |
+
"conditions": {
|
| 132 |
+
"boolean": [
|
| 133 |
+
{
|
| 134 |
+
"value1": "={{$json.ok}}",
|
| 135 |
+
"value2": true
|
| 136 |
+
}
|
| 137 |
+
]
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
"id": "validation-success",
|
| 141 |
+
"name": "Validation Success?",
|
| 142 |
+
"type": "n8n-nodes-base.if",
|
| 143 |
+
"typeVersion": 1,
|
| 144 |
+
"position": [1900, 0]
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"parameters": {
|
| 148 |
+
"jsCode": "const input = items[0].json;\nconst config = $('Central Config').first().json;\nif (config.googleSheetsWebhookUrl) {\n await this.helpers.httpRequest({ method: 'POST', url: config.googleSheetsWebhookUrl, body: input.googleSheetsRow, json: true, timeout: 30000 }).catch(() => undefined);\n}\nif (config.telegramBotToken && config.telegramChatId) {\n await this.helpers.httpRequest({ method: 'POST', url: `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`, body: { chat_id: config.telegramChatId, text: input.telegramMessage }, json: true, timeout: 30000 }).catch(() => undefined);\n}\nreturn [{ json: { ...input, notifications_sent: true } }];"
|
| 149 |
+
},
|
| 150 |
+
"id": "google-sheets-telegram-success",
|
| 151 |
+
"name": "Google Sheets + Telegram Success",
|
| 152 |
+
"type": "n8n-nodes-base.code",
|
| 153 |
+
"typeVersion": 2,
|
| 154 |
+
"position": [2160, -120]
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"parameters": {
|
| 158 |
+
"jsCode": "const input = items[0].json;\nconst config = $('Central Config').first().json;\nif (config.googleSheetsWebhookUrl) {\n await this.helpers.httpRequest({ method: 'POST', url: config.googleSheetsWebhookUrl, body: input.googleSheetsRow, json: true, timeout: 30000 }).catch(() => undefined);\n}\nif (config.telegramBotToken && config.telegramChatId) {\n await this.helpers.httpRequest({ method: 'POST', url: `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`, body: { chat_id: config.telegramChatId, text: input.telegramMessage }, json: true, timeout: 30000 }).catch(() => undefined);\n}\nreturn [{ json: { ...input, failure_notified: true } }];"
|
| 159 |
+
},
|
| 160 |
+
"id": "telegram-failure",
|
| 161 |
+
"name": "Telegram Failure + Failure Log",
|
| 162 |
+
"type": "n8n-nodes-base.code",
|
| 163 |
+
"typeVersion": 2,
|
| 164 |
+
"position": [2160, 120]
|
| 165 |
+
}
|
| 166 |
+
],
|
| 167 |
+
"connections": {
|
| 168 |
+
"Schedule Trigger - Daily": {
|
| 169 |
+
"main": [[{ "node": "Central Config", "type": "main", "index": 0 }]]
|
| 170 |
+
},
|
| 171 |
+
"Central Config": {
|
| 172 |
+
"main": [[{ "node": "Islamic Motivation Agent - GPT-4o", "type": "main", "index": 0 }]]
|
| 173 |
+
},
|
| 174 |
+
"Islamic Motivation Agent - GPT-4o": {
|
| 175 |
+
"main": [[{ "node": "Parse Agent Output", "type": "main", "index": 0 }]]
|
| 176 |
+
},
|
| 177 |
+
"Parse Agent Output": {
|
| 178 |
+
"main": [[{ "node": "Scene Generator - GPT-4o", "type": "main", "index": 0 }]]
|
| 179 |
+
},
|
| 180 |
+
"Scene Generator - GPT-4o": {
|
| 181 |
+
"main": [[{ "node": "Parse Scene JSON", "type": "main", "index": 0 }]]
|
| 182 |
+
},
|
| 183 |
+
"Parse Scene JSON": {
|
| 184 |
+
"main": [[{ "node": "Render/Edit/Publish with Maester", "type": "main", "index": 0 }]]
|
| 185 |
+
},
|
| 186 |
+
"Render/Edit/Publish with Maester": {
|
| 187 |
+
"main": [[{ "node": "Validation Success?", "type": "main", "index": 0 }]]
|
| 188 |
+
},
|
| 189 |
+
"Validation Success?": {
|
| 190 |
+
"main": [
|
| 191 |
+
[{ "node": "Google Sheets + Telegram Success", "type": "main", "index": 0 }],
|
| 192 |
+
[{ "node": "Telegram Failure + Failure Log", "type": "main", "index": 0 }]
|
| 193 |
+
]
|
| 194 |
+
}
|
| 195 |
+
},
|
| 196 |
+
"pinData": {},
|
| 197 |
+
"settings": {
|
| 198 |
+
"executionOrder": "v1"
|
| 199 |
+
},
|
| 200 |
+
"staticData": null,
|
| 201 |
+
"tags": [
|
| 202 |
+
{ "name": "maester" },
|
| 203 |
+
{ "name": "islamic-motivation" },
|
| 204 |
+
{ "name": "tiktok" },
|
| 205 |
+
{ "name": "pexels" },
|
| 206 |
+
{ "name": "archive-org" }
|
| 207 |
+
],
|
| 208 |
+
"triggerCount": 1,
|
| 209 |
+
"versionId": "maester-islamic-motivation-tiktok-v1"
|
| 210 |
+
}
|
workflows/n8n/tiktok_storytelling_pexels.json
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Maester - TikTok Storytelling Mini Series with Pexels",
|
| 3 |
+
"nodes": [
|
| 4 |
+
{
|
| 5 |
+
"parameters": {},
|
| 6 |
+
"id": "manual-trigger",
|
| 7 |
+
"name": "Manual Trigger",
|
| 8 |
+
"type": "n8n-nodes-base.manualTrigger",
|
| 9 |
+
"typeVersion": 1,
|
| 10 |
+
"position": [0, 0]
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"parameters": {
|
| 14 |
+
"jsCode": "const maesterBaseUrl = $env.MAESTER_BASE_URL || 'http://localhost:7860';\nconst maesterApiKey = $env.MAESTER_API_KEY || 'replace_with_maester_api_key';\nconst pexelsApiKey = $env.PEXELS_API_KEY || 'replace_with_pexels_api_key';\n\nreturn [{\n json: {\n maesterBaseUrl,\n maesterApiKey,\n pexelsApiKey,\n seriesTitle: 'The Hidden Cost of Ignoring AI Automation',\n episodeNumber: 1,\n topic: 'a founder who saves their business by automating repetitive work',\n audience: 'small business owners and creators',\n platform: 'tiktok',\n sceneCount: 5,\n sceneDuration: 5,\n template: 'tiktok_classic',\n creativeStyle: 'fast_hook',\n outputName: 'tiktok_storytelling_episode_01.mp4',\n backgroundEffect: 'blur',\n effects: ['slow_zoom', 'push_in', 'film_grain', 'vignette', 'caption_pop'],\n musicVolume: 0.22,\n captionTone: 'dramatic, simple, curiosity-driven'\n }\n}];"
|
| 15 |
+
},
|
| 16 |
+
"id": "configure-story",
|
| 17 |
+
"name": "Configure Story",
|
| 18 |
+
"type": "n8n-nodes-base.code",
|
| 19 |
+
"typeVersion": 2,
|
| 20 |
+
"position": [240, 0]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"parameters": {
|
| 24 |
+
"jsCode": "const input = items[0].json;\nconst title = input.seriesTitle;\nconst topic = input.topic;\nconst audience = input.audience;\nconst count = Number(input.sceneCount || 5);\nconst duration = Number(input.sceneDuration || 5);\n\nconst sceneTemplates = [\n {\n query: `${topic} stressed entrepreneur office night`,\n caption: `Episode ${input.episodeNumber}: Nobody noticed the warning signs.`\n },\n {\n query: `${topic} business owner overwhelmed laptop paperwork`,\n caption: 'Every day, the same tasks stole hours from the business.'\n },\n {\n query: `${topic} automation dashboard computer workflow`,\n caption: 'Then one workflow changed the entire routine.'\n },\n {\n query: `${topic} team celebrating small business success`,\n caption: 'The work did not disappear. It finally moved faster.'\n },\n {\n query: `${topic} creator planning content calendar phone`,\n caption: 'Follow for part two: the exact workflow behind it.'\n }\n];\n\nconst scenes = Array.from({ length: count }, (_, index) => {\n const template = sceneTemplates[index % sceneTemplates.length];\n return {\n index,\n start: index * duration,\n duration,\n query: template.query,\n caption: template.caption,\n transition: index === 0 ? 'fade' : 'smooth',\n background: input.backgroundEffect,\n effect: input.effects[index % input.effects.length],\n layout: 'fill'\n };\n});\n\nreturn [{ json: { ...input, title, audience, scenes } }];"
|
| 25 |
+
},
|
| 26 |
+
"id": "build-scenes",
|
| 27 |
+
"name": "Build Story Scenes",
|
| 28 |
+
"type": "n8n-nodes-base.code",
|
| 29 |
+
"typeVersion": 2,
|
| 30 |
+
"position": [500, 0]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"parameters": {
|
| 34 |
+
"jsCode": "const input = items[0].json;\nif (!input.pexelsApiKey || input.pexelsApiKey === 'replace_with_pexels_api_key') {\n throw new Error('Set PEXELS_API_KEY in n8n environment variables.');\n}\n\nasync function searchPexelsVideo(query) {\n const response = await this.helpers.httpRequest({\n method: 'GET',\n url: 'https://api.pexels.com/videos/search',\n headers: { Authorization: input.pexelsApiKey },\n qs: {\n query,\n orientation: 'portrait',\n size: 'medium',\n per_page: 5\n },\n json: true\n });\n\n const videos = response.videos || [];\n if (!videos.length) {\n throw new Error(`No Pexels videos found for query: ${query}`);\n }\n\n const video = videos[0];\n const files = (video.video_files || [])\n .filter(file => file.file_type === 'video/mp4' && file.link)\n .sort((a, b) => {\n const aPortrait = (a.height || 0) > (a.width || 0) ? 1 : 0;\n const bPortrait = (b.height || 0) > (b.width || 0) ? 1 : 0;\n const aScore = aPortrait * 1000000 + (a.height || 0) * (a.width || 0);\n const bScore = bPortrait * 1000000 + (b.height || 0) * (b.width || 0);\n return bScore - aScore;\n });\n\n if (!files.length) {\n throw new Error(`Pexels result had no mp4 files for query: ${query}`);\n }\n\n return {\n media: files[0].link,\n pexels: {\n id: video.id,\n url: video.url,\n image: video.image,\n user: video.user?.name,\n query\n }\n };\n}\n\nconst resolvedScenes = [];\nfor (const scene of input.scenes) {\n const asset = await searchPexelsVideo.call(this, scene.query);\n resolvedScenes.push({\n start: scene.start,\n duration: scene.duration,\n media: asset.media,\n caption: scene.caption,\n transition: scene.transition,\n background: scene.background,\n effect: scene.effect,\n layout: scene.layout,\n metadata: { pexels: asset.pexels }\n });\n}\n\nconst renderPayload = {\n template: input.template,\n creative_style: input.creativeStyle,\n platform: input.platform,\n output_name: input.outputName,\n subtitle_format: 'ass',\n auto_subtitles: false,\n normalize: true,\n audio_normalize: true,\n music_volume: input.musicVolume,\n metadata: {\n series_title: input.seriesTitle,\n episode_number: input.episodeNumber,\n topic: input.topic,\n audience: input.audience,\n source: 'n8n_pexels_storytelling_workflow'\n },\n scenes: resolvedScenes\n};\n\nreturn [{ json: { ...input, resolvedScenes, renderPayload } }];"
|
| 35 |
+
},
|
| 36 |
+
"id": "search-pexels",
|
| 37 |
+
"name": "Search Pexels Videos",
|
| 38 |
+
"type": "n8n-nodes-base.code",
|
| 39 |
+
"typeVersion": 2,
|
| 40 |
+
"position": [780, 0]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"parameters": {
|
| 44 |
+
"method": "POST",
|
| 45 |
+
"url": "={{$json.maesterBaseUrl + '/services/render/render'}}",
|
| 46 |
+
"sendHeaders": true,
|
| 47 |
+
"headerParameters": {
|
| 48 |
+
"parameters": [
|
| 49 |
+
{
|
| 50 |
+
"name": "X-API-Key",
|
| 51 |
+
"value": "={{$json.maesterApiKey}}"
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"name": "Content-Type",
|
| 55 |
+
"value": "application/json"
|
| 56 |
+
}
|
| 57 |
+
]
|
| 58 |
+
},
|
| 59 |
+
"sendBody": true,
|
| 60 |
+
"contentType": "json",
|
| 61 |
+
"jsonBody": "={{JSON.stringify($json.renderPayload)}}",
|
| 62 |
+
"options": {
|
| 63 |
+
"timeout": 180000
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
"id": "submit-render",
|
| 67 |
+
"name": "Submit Render to Maester",
|
| 68 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 69 |
+
"typeVersion": 4.2,
|
| 70 |
+
"position": [1060, 0]
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"parameters": {
|
| 74 |
+
"jsCode": "const response = items[0].json;\nreturn [{\n json: {\n status: 'submitted',\n render_response: response,\n next_steps: [\n 'Use the returned status_url to poll render progress.',\n 'Use the returned download_url when the job is completed.',\n 'Add a Wait node and HTTP Request node if you want automatic polling.'\n ]\n }\n}];"
|
| 75 |
+
},
|
| 76 |
+
"id": "final-response",
|
| 77 |
+
"name": "Return Render Job",
|
| 78 |
+
"type": "n8n-nodes-base.code",
|
| 79 |
+
"typeVersion": 2,
|
| 80 |
+
"position": [1320, 0]
|
| 81 |
+
}
|
| 82 |
+
],
|
| 83 |
+
"connections": {
|
| 84 |
+
"Manual Trigger": {
|
| 85 |
+
"main": [
|
| 86 |
+
[
|
| 87 |
+
{
|
| 88 |
+
"node": "Configure Story",
|
| 89 |
+
"type": "main",
|
| 90 |
+
"index": 0
|
| 91 |
+
}
|
| 92 |
+
]
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
"Configure Story": {
|
| 96 |
+
"main": [
|
| 97 |
+
[
|
| 98 |
+
{
|
| 99 |
+
"node": "Build Story Scenes",
|
| 100 |
+
"type": "main",
|
| 101 |
+
"index": 0
|
| 102 |
+
}
|
| 103 |
+
]
|
| 104 |
+
]
|
| 105 |
+
},
|
| 106 |
+
"Build Story Scenes": {
|
| 107 |
+
"main": [
|
| 108 |
+
[
|
| 109 |
+
{
|
| 110 |
+
"node": "Search Pexels Videos",
|
| 111 |
+
"type": "main",
|
| 112 |
+
"index": 0
|
| 113 |
+
}
|
| 114 |
+
]
|
| 115 |
+
]
|
| 116 |
+
},
|
| 117 |
+
"Search Pexels Videos": {
|
| 118 |
+
"main": [
|
| 119 |
+
[
|
| 120 |
+
{
|
| 121 |
+
"node": "Submit Render to Maester",
|
| 122 |
+
"type": "main",
|
| 123 |
+
"index": 0
|
| 124 |
+
}
|
| 125 |
+
]
|
| 126 |
+
]
|
| 127 |
+
},
|
| 128 |
+
"Submit Render to Maester": {
|
| 129 |
+
"main": [
|
| 130 |
+
[
|
| 131 |
+
{
|
| 132 |
+
"node": "Return Render Job",
|
| 133 |
+
"type": "main",
|
| 134 |
+
"index": 0
|
| 135 |
+
}
|
| 136 |
+
]
|
| 137 |
+
]
|
| 138 |
+
}
|
| 139 |
+
},
|
| 140 |
+
"pinData": {},
|
| 141 |
+
"settings": {
|
| 142 |
+
"executionOrder": "v1"
|
| 143 |
+
},
|
| 144 |
+
"staticData": null,
|
| 145 |
+
"tags": [
|
| 146 |
+
{
|
| 147 |
+
"name": "maester"
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"name": "tiktok"
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"name": "pexels"
|
| 154 |
+
}
|
| 155 |
+
],
|
| 156 |
+
"triggerCount": 0,
|
| 157 |
+
"updatedAt": "2026-07-13T00:00:00.000Z",
|
| 158 |
+
"versionId": "maester-tiktok-storytelling-pexels-v1"
|
| 159 |
+
}
|
workflows/n8n/webhook_tiktok_storytelling_pexels.json
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Maester - Webhook TikTok Storytelling with Pexels",
|
| 3 |
+
"nodes": [
|
| 4 |
+
{
|
| 5 |
+
"parameters": {
|
| 6 |
+
"httpMethod": "POST",
|
| 7 |
+
"path": "maester/create-tiktok-story",
|
| 8 |
+
"responseMode": "lastNode",
|
| 9 |
+
"options": {
|
| 10 |
+
"responseCode": 202
|
| 11 |
+
}
|
| 12 |
+
},
|
| 13 |
+
"id": "webhook-trigger",
|
| 14 |
+
"name": "Create TikTok Story Webhook",
|
| 15 |
+
"type": "n8n-nodes-base.webhook",
|
| 16 |
+
"typeVersion": 2,
|
| 17 |
+
"position": [
|
| 18 |
+
0,
|
| 19 |
+
0
|
| 20 |
+
],
|
| 21 |
+
"webhookId": "maester-create-tiktok-story"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"parameters": {
|
| 25 |
+
"jsCode": "const payload = items[0].json.body || items[0].json;\nconst maesterBaseUrl = ($env.MAESTER_BASE_URL || 'http://maester-enterprise:7860').replace(/\\/$/, '');\nconst maesterApiKey = $env.MAESTER_API_KEY || 'replace_with_maester_api_key';\nconst pexelsApiKey = $env.PEXELS_API_KEY || 'replace_with_pexels_api_key';\n\nif (!maesterApiKey || maesterApiKey === 'replace_with_maester_api_key') {\n throw new Error('Set MAESTER_API_KEY in the n8n environment.');\n}\nif (!pexelsApiKey || pexelsApiKey === 'replace_with_pexels_api_key') {\n throw new Error('Set PEXELS_API_KEY in the n8n environment.');\n}\n\nconst topic = payload.topic || 'a creator builds a simple automation that saves their day';\nconst sceneCount = Math.max(1, Math.min(Number(payload.scene_count || payload.sceneCount || 5), 8));\nconst sceneDuration = Math.max(3, Math.min(Number(payload.scene_duration || payload.sceneDuration || 5), 12));\n\nreturn [{\n json: {\n maesterBaseUrl,\n maesterApiKey,\n pexelsApiKey,\n topic,\n seriesTitle: payload.series_title || payload.seriesTitle || 'Maester Automation Story',\n episodeNumber: Number(payload.episode_number || payload.episodeNumber || 1),\n audience: payload.audience || 'short-form video viewers',\n platform: payload.platform || 'tiktok',\n sceneCount,\n sceneDuration,\n template: payload.template || 'tiktok_classic',\n creativeStyle: payload.creative_style || payload.creativeStyle || 'fast_hook',\n outputName: payload.output_name || payload.outputName || 'maester_webhook_tiktok_story.mp4',\n backgroundEffect: payload.background_effect || payload.backgroundEffect || 'blur',\n effects: payload.effects || ['slow_zoom', 'push_in', 'film_grain', 'vignette', 'caption_pop'],\n musicVolume: Number(payload.music_volume || payload.musicVolume || 0.22)\n }\n}];"
|
| 26 |
+
},
|
| 27 |
+
"id": "normalize-request",
|
| 28 |
+
"name": "Normalize Request",
|
| 29 |
+
"type": "n8n-nodes-base.code",
|
| 30 |
+
"typeVersion": 2,
|
| 31 |
+
"position": [
|
| 32 |
+
260,
|
| 33 |
+
0
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"parameters": {
|
| 38 |
+
"jsCode": "const input = items[0].json;\nconst templates = [\n {\n query: `${input.topic} stressed person working late vertical video`,\n caption: `Episode ${input.episodeNumber}: The problem was hiding in plain sight.`\n },\n {\n query: `${input.topic} laptop workflow dashboard vertical video`,\n caption: 'The repetitive work kept taking the best hours.'\n },\n {\n query: `${input.topic} automation technology computer vertical video`,\n caption: 'Then one workflow changed the routine.'\n },\n {\n query: `${input.topic} creator planning content calendar phone vertical video`,\n caption: 'The system started moving before the day got busy.'\n },\n {\n query: `${input.topic} successful small business owner team vertical video`,\n caption: 'The result: more output, less manual chasing.'\n }\n];\n\nconst scenes = Array.from({ length: input.sceneCount }, (_, index) => {\n const template = templates[index % templates.length];\n return {\n index,\n start: index * input.sceneDuration,\n duration: input.sceneDuration,\n query: template.query,\n caption: template.caption,\n transition: index === 0 ? 'fade' : 'smooth',\n background: input.backgroundEffect,\n effect: input.effects[index % input.effects.length],\n layout: 'fill'\n };\n});\n\nreturn [{ json: { ...input, scenes } }];"
|
| 39 |
+
},
|
| 40 |
+
"id": "build-scenes",
|
| 41 |
+
"name": "Build Scenes",
|
| 42 |
+
"type": "n8n-nodes-base.code",
|
| 43 |
+
"typeVersion": 2,
|
| 44 |
+
"position": [
|
| 45 |
+
520,
|
| 46 |
+
0
|
| 47 |
+
]
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"parameters": {
|
| 51 |
+
"jsCode": "const input = items[0].json;\n\nasync function searchPexelsVideo(query) {\n const response = await this.helpers.httpRequest({\n method: 'GET',\n url: 'https://api.pexels.com/videos/search',\n headers: { Authorization: input.pexelsApiKey },\n qs: {\n query,\n orientation: 'portrait',\n size: 'medium',\n per_page: 8\n },\n json: true,\n timeout: 90000\n });\n\n const videos = response.videos || [];\n if (!videos.length) {\n throw new Error(`No Pexels videos found for query: ${query}`);\n }\n\n for (const video of videos) {\n const files = (video.video_files || [])\n .filter(file => file.file_type === 'video/mp4' && file.link)\n .sort((a, b) => {\n const aPortrait = (a.height || 0) > (a.width || 0) ? 1 : 0;\n const bPortrait = (b.height || 0) > (b.width || 0) ? 1 : 0;\n const aScore = aPortrait * 100000000 + (a.height || 0) * (a.width || 0);\n const bScore = bPortrait * 100000000 + (b.height || 0) * (b.width || 0);\n return bScore - aScore;\n });\n if (files.length) {\n return {\n media: files[0].link,\n pexels: {\n id: video.id,\n url: video.url,\n image: video.image,\n user: video.user?.name,\n query,\n width: files[0].width,\n height: files[0].height\n }\n };\n }\n }\n\n throw new Error(`Pexels returned no usable mp4 files for query: ${query}`);\n}\n\nconst resolvedScenes = [];\nfor (const scene of input.scenes) {\n const asset = await searchPexelsVideo.call(this, scene.query);\n resolvedScenes.push({\n start: scene.start,\n duration: scene.duration,\n media: asset.media,\n caption: scene.caption,\n transition: scene.transition,\n background: scene.background,\n effect: scene.effect,\n layout: scene.layout,\n metadata: { pexels: asset.pexels }\n });\n}\n\nconst renderPayload = {\n template: input.template,\n creative_style: input.creativeStyle,\n platform: input.platform,\n output_name: input.outputName,\n subtitle_format: 'ass',\n auto_subtitles: false,\n normalize: true,\n audio_normalize: true,\n music_volume: input.musicVolume,\n metadata: {\n series_title: input.seriesTitle,\n episode_number: input.episodeNumber,\n topic: input.topic,\n audience: input.audience,\n source: 'n8n_webhook_pexels_storytelling_workflow'\n },\n scenes: resolvedScenes\n};\n\nreturn [{ json: { ...input, renderPayload } }];"
|
| 52 |
+
},
|
| 53 |
+
"id": "search-pexels",
|
| 54 |
+
"name": "Search Pexels Videos",
|
| 55 |
+
"type": "n8n-nodes-base.code",
|
| 56 |
+
"typeVersion": 2,
|
| 57 |
+
"position": [
|
| 58 |
+
780,
|
| 59 |
+
0
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"parameters": {
|
| 64 |
+
"method": "POST",
|
| 65 |
+
"url": "={{$json.maesterBaseUrl + '/services/render/render'}}",
|
| 66 |
+
"sendHeaders": true,
|
| 67 |
+
"headerParameters": {
|
| 68 |
+
"parameters": [
|
| 69 |
+
{
|
| 70 |
+
"name": "X-API-Key",
|
| 71 |
+
"value": "={{$json.maesterApiKey}}"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"name": "Content-Type",
|
| 75 |
+
"value": "application/json"
|
| 76 |
+
}
|
| 77 |
+
]
|
| 78 |
+
},
|
| 79 |
+
"sendBody": true,
|
| 80 |
+
"contentType": "json",
|
| 81 |
+
"jsonBody": "={{JSON.stringify($json.renderPayload)}}",
|
| 82 |
+
"options": {
|
| 83 |
+
"timeout": 180000
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
"id": "submit-render",
|
| 87 |
+
"name": "Submit Render to Maester",
|
| 88 |
+
"type": "n8n-nodes-base.httpRequest",
|
| 89 |
+
"typeVersion": 4.2,
|
| 90 |
+
"position": [
|
| 91 |
+
1060,
|
| 92 |
+
0
|
| 93 |
+
]
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
"connections": {
|
| 97 |
+
"Create TikTok Story Webhook": {
|
| 98 |
+
"main": [
|
| 99 |
+
[
|
| 100 |
+
{
|
| 101 |
+
"node": "Normalize Request",
|
| 102 |
+
"type": "main",
|
| 103 |
+
"index": 0
|
| 104 |
+
}
|
| 105 |
+
]
|
| 106 |
+
]
|
| 107 |
+
},
|
| 108 |
+
"Normalize Request": {
|
| 109 |
+
"main": [
|
| 110 |
+
[
|
| 111 |
+
{
|
| 112 |
+
"node": "Build Scenes",
|
| 113 |
+
"type": "main",
|
| 114 |
+
"index": 0
|
| 115 |
+
}
|
| 116 |
+
]
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
"Build Scenes": {
|
| 120 |
+
"main": [
|
| 121 |
+
[
|
| 122 |
+
{
|
| 123 |
+
"node": "Search Pexels Videos",
|
| 124 |
+
"type": "main",
|
| 125 |
+
"index": 0
|
| 126 |
+
}
|
| 127 |
+
]
|
| 128 |
+
]
|
| 129 |
+
},
|
| 130 |
+
"Search Pexels Videos": {
|
| 131 |
+
"main": [
|
| 132 |
+
[
|
| 133 |
+
{
|
| 134 |
+
"node": "Submit Render to Maester",
|
| 135 |
+
"type": "main",
|
| 136 |
+
"index": 0
|
| 137 |
+
}
|
| 138 |
+
]
|
| 139 |
+
]
|
| 140 |
+
}
|
| 141 |
+
},
|
| 142 |
+
"settings": {
|
| 143 |
+
"executionOrder": "v1"
|
| 144 |
+
},
|
| 145 |
+
"staticData": null,
|
| 146 |
+
"pinData": {},
|
| 147 |
+
"versionId": "maester-webhook-tiktok-storytelling-pexels",
|
| 148 |
+
"triggerCount": 0,
|
| 149 |
+
"tags": [
|
| 150 |
+
{
|
| 151 |
+
"name": "maester"
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"name": "webhook"
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"name": "tiktok"
|
| 158 |
+
}
|
| 159 |
+
]
|
| 160 |
+
}
|