This workflow corresponds to n8n.io template #16139 — we link there as the canonical source.
This workflow follows the Chainllm → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "ZJq4JKOm81uu2cLw",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "AI Video Translation and Subtitle Rendering Template",
"tags": [],
"nodes": [
{
"id": "5ddfca70-4e28-4b96-aa14-9e16f7077018",
"name": "Transcribe: Submit to AssemblyAI",
"type": "n8n-nodes-base.httpRequest",
"position": [
1872,
112
],
"parameters": {
"url": "https://api.assemblyai.com/v2/transcript",
"method": "POST",
"options": {},
"jsonBody": "={\n \"audio_url\": \"{{ $('Prepare: Convert Drive Share URL to Download URL').first().json.download_url }}\",\n \"language_code\": \"en\",\n \"speech_models\": [\"universal-3-pro\", \"universal-2\"],\n \"punctuate\": false,\n \"format_text\": false,\n \"speech_understanding\": {\n \"request\": {\n \"translation\": {\n \"target_languages\": [\"ja\"]\n }\n }\n }\n}",
"sendBody": true,
"specifyBody": "json",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "c501325f-bbfd-4d1f-a571-b54ca74f7723",
"name": "Wait: AssemblyAI Processing",
"type": "n8n-nodes-base.wait",
"position": [
2096,
112
],
"parameters": {
"amount": 300
},
"typeVersion": 1.1
},
{
"id": "cc7b089b-de50-4df6-9594-f452866c2992",
"name": "Check: AssemblyAI Transcript Status",
"type": "n8n-nodes-base.httpRequest",
"position": [
2320,
32
],
"parameters": {
"url": "=https://api.assemblyai.com/v2/transcript/{{ $('Transcribe: Submit to AssemblyAI').item.json.id }}",
"options": {},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "dbd0dad5-9e59-4d17-bcac-7759da9c1d16",
"name": "Wait: Creatomate Rendering",
"type": "n8n-nodes-base.wait",
"position": [
3568,
112
],
"parameters": {
"amount": 300
},
"typeVersion": 1.1
},
{
"id": "f383bd1d-b065-4bdf-be32-945eccea948e",
"name": "Check: Creatomate Render Status",
"type": "n8n-nodes-base.httpRequest",
"position": [
3792,
32
],
"parameters": {
"url": "=https://api.creatomate.com/v2/renders/{{ $json.id }}",
"options": {},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "c6af0c1a-76ff-4904-8550-13b97c62b867",
"name": "Check: Render Completed?",
"type": "n8n-nodes-base.if",
"position": [
4016,
112
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "18c3fa02-29d7-4f53-af46-12758cc14326",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.status }}",
"rightValue": "succeeded"
}
]
}
},
"typeVersion": 2.3
},
{
"id": "d172bebe-9f9f-421b-9f9e-2f40f1b7ce14",
"name": "Extract: Parse Latest Video URL",
"type": "n8n-nodes-base.code",
"position": [
1200,
112
],
"parameters": {
"jsCode": "const rows = $input.all();\n\nif (rows.length === 0) {\n return [];\n}\n\n// row_number \u304c\u4e00\u756a\u5927\u304d\u3044\u884c\u3092\u53d6\u5f97\nconst lastRow = rows.sort((a, b) => {\n return Number(a.json.row_number) - Number(b.json.row_number);\n}).at(-1);\n\n// row_number \u4ee5\u5916\u306e\u5024\u3092\u53d6\u308a\u51fa\u3059\nconst values = Object.entries(lastRow.json)\n .filter(([key, value]) => key !== 'row_number')\n .map(([key, value]) => value)\n .filter(value => value !== null && value !== undefined && String(value).trim() !== '');\n\n// URL\u3089\u3057\u3044\u5024\u3092\u53d6\u5f97\nconst url = values.find(value => {\n return String(value).startsWith('http://') || String(value).startsWith('https://');\n});\n\nreturn [\n {\n json: {\n row_number: lastRow.json.row_number,\n url: url || ''\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "6dc76f32-17bd-4072-8015-706f2a474db6",
"name": "Trigger: Watch New Video Rows",
"type": "n8n-nodes-base.googleSheetsTrigger",
"position": [
976,
112
],
"parameters": {
"event": "rowAdded",
"options": {},
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iJ5_iwna9y0J10wXIUEcGFpQ1cdF08BqCy0tsOxZM9s/edit#gid=0",
"cachedResultName": "URL\u8cbc\u308a\u4ed8\u3051"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1iJ5_iwna9y0J10wXIUEcGFpQ1cdF08BqCy0tsOxZM9s",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iJ5_iwna9y0J10wXIUEcGFpQ1cdF08BqCy0tsOxZM9s/edit?usp=drivesdk",
"cachedResultName": "\u3010n8n\u3011\u52d5\u753b\u30c6\u30ed\u30c3\u30d7\u81ea\u52d5\u5316\u30b7\u30b9\u30c6\u30e0"
}
},
"credentials": {
"googleSheetsTriggerOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "db3d03cf-3ef8-4dcc-8a12-3ad721fc7fe9",
"name": "Model: Gemini Translation",
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"position": [
2768,
256
],
"parameters": {
"options": {},
"modelName": "models/gemini-3-flash-preview"
},
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "dfcc1196-9877-4fe1-af13-69b0000d232a",
"name": "Fetch: Video Metadata",
"type": "n8n-nodes-base.httpRequest",
"position": [
1648,
112
],
"parameters": {
"url": "=https://www.googleapis.com/drive/v3/files/{{ $json.download_url.match(/[?&]id=([^&]+)/)[1] }}?fields=id,name,mimeType,videoMediaMetadata(width,height,durationMillis)&supportsAllDrives=true",
"options": {},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDriveOAuth2Api"
},
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "6c0e83c9-95d0-4ccd-b58d-667462dccb10",
"name": "WORKFLOW OVERVIEW",
"type": "n8n-nodes-base.stickyNote",
"position": [
-48,
-160
],
"parameters": {
"width": 920,
"height": 1288,
"content": "## AUTOMATE ENGLISH-TO-JAPANESE VIDEO TRANSCRIPTION, TRANSLATION, AND RENDERING WITH ASSEMBLYAI AND CREATOMATE\n\n## Who\u2019s it for\nCreators, educators, marketers, and small teams who use n8n Cloud to automatically turn uploaded English videos into Japanese-subtitled videos without manually transcribing, translating, timing subtitles, or rendering the final file.\n\n## What it does\nThis workflow watches a Google Sheet for a new video URL, converts the Google Drive sharing link into a usable download URL, reads the video metadata, sends the audio/video to AssemblyAI for English transcription and Japanese translation, improves the Japanese text with Gemini, builds timed subtitles and a Creatomate render script, renders the subtitled video, checks the render status, and posts the completed video URL to Slack.\n\n## How it works\n1. A Google Sheets trigger starts the workflow when a new row is added.\n2. The latest video URL is extracted and normalized.\n3. Google Drive metadata is fetched to determine whether the video is portrait or landscape.\n4. AssemblyAI generates English transcription and Japanese translation.\n5. The workflow automatically checks the status and loops until processing is complete.\n6. Gemini polishes the Japanese subtitle text without changing the meaning.\n7. Code node builds subtitle segments and the Creatomate render payload.\n8. Creatomate renders the final subtitled video.\n9. The workflow loops until rendering succeeds, then sends the result to Slack.\n\n## Requirements\n- Google Sheets OAuth2 credential\n- Google Drive OAuth2 credential\n- AssemblyAI HTTP Header Auth credential\n- Google Gemini API credential\n- Creatomate HTTP Header Auth credential\n- Slack OAuth2 credential\n- A Google Sheet containing video URLs\n- A Slack channel for the final video link\n\n## How to set up\n\n1. Download the MP4 video file that you want to translate.\n2. Upload the MP4 file to Google Drive.\n3. Open the file's sharing settings and change the access from **Restricted** to **Anyone with the link**.\n4. Copy the Google Drive sharing link.\n5. Add your API credentials in n8n for Google Sheets, Google Drive, AssemblyAI, Gemini, Creatomate, and Slack.\n6. Update the Google Sheets and Slack node settings to match your own environment and resources.\n7. Confirm that the input column contains a valid Google Drive video URL.\n8. Activate the workflow.\n9. Paste the sharing link into the designated input column in your Google Sheet.\n\nOnce the workflow is activated, simply paste a new Google Drive video URL into the configured Google Sheet. The workflow automatically detects the new row, starts the transcription and translation process, generates Japanese subtitles, renders the final subtitled video, and sends the completed video link to Slack without any manual intervention.\n\n\n## How to customize\n- Change the source from Google Sheets to another trigger.\n- Edit the Gemini prompt for a different subtitle style.\n- Update the Creatomate template or render settings.\n- Send the final result to email, Notion, Google Drive, or another destination instead of Slack."
},
"typeVersion": 1
},
{
"id": "299025e1-160b-4b3d-81c3-5828f75d22eb",
"name": "STEP1",
"type": "n8n-nodes-base.stickyNote",
"position": [
880,
-160
],
"parameters": {
"color": 7,
"width": 480,
"height": 484,
"content": "## STEP1: Video URL Intake\nWatch a Google Sheet for a newly added row, parse the latest row, and extract the usable video URL from the row data."
},
"typeVersion": 1
},
{
"id": "9c2c2628-1cbe-463f-8272-4b975329689c",
"name": "STEP2",
"type": "n8n-nodes-base.stickyNote",
"position": [
1360,
-160
],
"parameters": {
"color": 7,
"width": 448,
"height": 484,
"content": "## STEP2: Prepare Download URL and Retrieve Video Metadata\nNormalize the Google Drive sharing link into a direct download URL, then fetch video metadata such as width and height for subtitle layout decisions."
},
"typeVersion": 1
},
{
"id": "3604e79c-fa7f-4b6d-b221-d4ae837f9f2a",
"name": "STEP3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1808,
-160
],
"parameters": {
"color": 7,
"width": 912,
"height": 484,
"content": "## STEP3: Transcription & Translation\nSubmit the video URL to AssemblyAI, wait for processing, and check the transcript status to retrieve Japanese translation data.\n\nThe If node verifies whether transcription has completed. If processing is still running, the workflow waits and checks again until the result is ready."
},
"typeVersion": 1
},
{
"id": "8eef2907-2fa9-42fc-a149-896dc9a84855",
"name": "STEP4",
"type": "n8n-nodes-base.stickyNote",
"position": [
2720,
-160
],
"parameters": {
"color": 7,
"width": 568,
"height": 580,
"content": "## STEP4: Generate Natural Japanese Subtitles and Render Data\nConvert the translated text into natural subtitle-friendly Japanese, create subtitle timing information, and generate the render payload used to produce the final subtitled video."
},
"typeVersion": 1
},
{
"id": "fe6ec697-17e9-4701-805d-28b1a9598bd3",
"name": "STEP6",
"type": "n8n-nodes-base.stickyNote",
"position": [
3296,
-160
],
"parameters": {
"color": 7,
"width": 440,
"height": 516,
"content": "## STEP5: Create Subtitled Video\nSend the final subtitle data and video source to Creatomate.\nCreatomate combines the video and subtitles into a single rendered video.\nThe workflow waits until the finished video is ready for download."
},
"typeVersion": 1
},
{
"id": "c3d402ef-6d65-438e-8ab7-dd6758bf8c8c",
"name": "STEP7",
"type": "n8n-nodes-base.stickyNote",
"position": [
3728,
-160
],
"parameters": {
"color": 7,
"width": 728,
"height": 516,
"content": "## STEP6: Render Status Check & Notification\nCheck whether Creatomate rendering has succeeded. If not, wait and retry. Once complete, send the final video URL to Slack."
},
"typeVersion": 1
},
{
"id": "3fc5729c-4edb-4284-950e-c2de87b925cc",
"name": "Prepare: Convert Drive Share URL to Download URL",
"type": "n8n-nodes-base.set",
"position": [
1424,
112
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "a7a39887-e994-42f3-bc11-a1a7c808ae38",
"name": "download_url",
"type": "string",
"value": "={{ \"https://drive.google.com/uc?export=download&id=\" + $json.url.match(/\\/d\\/([^/]+)/)[1] }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "eccb7c0b-832d-4c8d-8c62-9a058d58573c",
"name": "Generate: Natural Subtitle Timeline and Creatomate Render Script",
"type": "n8n-nodes-base.code",
"position": [
3120,
112
],
"parameters": {
"jsCode": "// ======================================================\n// AI: Polish Japanese Translation\u3067\u6574\u3048\u305f\u65e5\u672c\u8a9e text \u3068\n// AssemblyAI \u306e words \u304b\u3089\n// \u65e5\u672c\u8a9e\u5b57\u5e55JSON\u914d\u5217\u3068 Creatomate\u7528RenderScript\u3092\u4f5c\u6210\u3059\u308b Code \u30ce\u30fc\u30c9\n//\n// \u5bfe\u5fdc\u5185\u5bb9\uff1a\n// - \u5b57\u5e55\u306f\u57fa\u672c\u7684\u306b1\u884c\u3067\u8868\u793a\u3059\u308b\n// - \u9577\u3044\u5b57\u5e55\u306f\u81ea\u7136\u306a\u6587\u7ae0\u533a\u5207\u308a\u3067\u5206\u5272\u3059\u308b\n// - \u6700\u5f8c\u306e\u5b57\u5e55\u306f\u5fc5\u305a AssemblyAI words \u306e\u6700\u5f8c\u306e end \u3067\u7d42\u308f\u308b\n// ======================================================\n\n\n// ======================================================\n// 1. AI: Polish Japanese Translation \u306e\u51fa\u529b\u3092\u53d6\u5f97\n// ======================================================\n\nconst llmInput = items[0].json;\n\n\n// ======================================================\n// 2. AssemblyAI GET\u7d50\u679c\u306e\u5143\u30c7\u30fc\u30bf\u3092\u53d6\u5f97\n// ======================================================\n\nconst originalNodeName = 'Check: AssemblyAI Transcript Status';\n\nlet originalInput;\n\ntry {\n originalInput = $(originalNodeName).item.json;\n} catch (error) {\n originalInput = $(originalNodeName).first().json;\n}\n\nif (!originalInput || Object.keys(originalInput).length === 0) {\n throw new Error(\n `\u5143\u30c7\u30fc\u30bf\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\u30ce\u30fc\u30c9\u540d\u300c${originalNodeName}\u300d\u304c\u6b63\u3057\u3044\u304b\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002`\n );\n}\n\n\n// ======================================================\n// 3. AI: Polish Japanese Translation \u306e\u51fa\u529b\u304b\u3089\u6574\u5f62\u6e08\u307f text \u3092\u53d6\u5f97\n// ======================================================\n\nfunction pickTextFromObject(obj) {\n if (!obj || typeof obj !== 'object') {\n return '';\n }\n\n const directKeys = [\n 'text',\n 'output',\n 'response',\n 'result',\n 'content',\n 'chat_message',\n 'completion'\n ];\n\n for (const key of directKeys) {\n if (typeof obj[key] === 'string' && obj[key].trim() !== '') {\n return obj[key];\n }\n }\n\n if (\n obj.message &&\n typeof obj.message.content === 'string' &&\n obj.message.content.trim() !== ''\n ) {\n return obj.message.content;\n }\n\n if (obj.data && typeof obj.data === 'object') {\n const textFromData = pickTextFromObject(obj.data);\n\n if (textFromData) {\n return textFromData;\n }\n }\n\n if (obj.output && typeof obj.output === 'object') {\n const textFromOutput = pickTextFromObject(obj.output);\n\n if (textFromOutput) {\n return textFromOutput;\n }\n }\n\n if (Array.isArray(obj.generations)) {\n const firstGeneration = obj.generations[0];\n\n if (firstGeneration) {\n if (\n typeof firstGeneration.text === 'string' &&\n firstGeneration.text.trim() !== ''\n ) {\n return firstGeneration.text;\n }\n\n if (\n firstGeneration.message &&\n typeof firstGeneration.message.content === 'string' &&\n firstGeneration.message.content.trim() !== ''\n ) {\n return firstGeneration.message.content;\n }\n }\n }\n\n return '';\n}\n\nlet formattedText = pickTextFromObject(llmInput);\n\n\n// ======================================================\n// 4. AI: Polish Japanese Translation \u306e\u51fa\u529b\u304c\u53d6\u308c\u306a\u3044\u5834\u5408\u306f AssemblyAI \u7ffb\u8a33\u3092\u88dc\u52a9\u7684\u306b\u4f7f\u3046\n// ======================================================\n\nif (\n !formattedText &&\n originalInput.translated_texts &&\n typeof originalInput.translated_texts.ja === 'string' &&\n originalInput.translated_texts.ja.trim() !== ''\n) {\n formattedText = originalInput.translated_texts.ja;\n}\n\nformattedText = String(formattedText || '').trim();\n\nformattedText = formattedText\n .replace(/^\u6574\u5f62\u5f8c\u306e\u672c\u6587[:\uff1a]\\s*/g, '')\n .replace(/^\u51fa\u529b[:\uff1a]\\s*/g, '')\n .replace(/^\u672c\u6587[:\uff1a]\\s*/g, '')\n .replace(/^\u7d50\u679c[:\uff1a]\\s*/g, '')\n .trim();\n\nif (!formattedText) {\n throw new Error(\n [\n 'AI: Polish Japanese Translation\u306e\u6574\u5f62\u6e08\u307ftext\u304c\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002',\n '\u307e\u305f\u3001AssemblyAI\u306e translated_texts.ja \u3082\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002',\n 'AI: Polish Japanese Translation\u306e\u5165\u529b\u304c {{ $json.translated_texts.ja }} \u306b\u306a\u3063\u3066\u3044\u308b\u304b\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002',\n '',\n '\u73fe\u5728\u306eAI: Polish Japanese Translation\u51fa\u529b:',\n JSON.stringify(llmInput, null, 2),\n '',\n '\u73fe\u5728\u306eAssemblyAI\u51fa\u529b:',\n JSON.stringify(originalInput, null, 2)\n ].join('\\n')\n );\n}\n\n\n// ======================================================\n// 5. text\u3060\u3051 AI: Polish Japanese Translation \u306e\u51fa\u529b\u306b\u5dee\u3057\u66ff\u3048\u308b\n// ======================================================\n\nconst input = {\n ...originalInput,\n text: formattedText,\n original_text_before_llm: originalInput.text || '',\n translated_ja_before_llm: originalInput.translated_texts?.ja || '',\n text_source: 'AI: Polish Japanese Translation or AssemblyAI translated_texts.ja',\n original_data_source: originalNodeName\n};\n\n\n// ======================================================\n// 6. AssemblyAI \u306e\u51e6\u7406\u5b8c\u4e86\u30c1\u30a7\u30c3\u30af\n// ======================================================\n\nif (input.status !== 'completed') {\n throw new Error(\n `AssemblyAI\u306e\u6587\u5b57\u8d77\u3053\u3057\u304c\u307e\u3060\u5b8c\u4e86\u3057\u3066\u3044\u307e\u305b\u3093\u3002\u73fe\u5728\u306estatus: ${input.status}`\n );\n}\n\n\n// ======================================================\n// 7. \u5b57\u5e55\u7528\u306e\u65e5\u672c\u8a9e text \u3092\u53d6\u5f97\n// ======================================================\n\nconst textForSubtitle = String(input.text || '').trim();\n\nif (!textForSubtitle) {\n throw new Error(\n 'input.text \u304c\u7a7a\u3067\u3059\u3002\u65e5\u672c\u8a9e\u5b57\u5e55\u7528\u30c6\u30ad\u30b9\u30c8\u304c\u53d6\u5f97\u3067\u304d\u3066\u3044\u307e\u305b\u3093\u3002'\n );\n}\n\n\n// ======================================================\n// 9. AssemblyAI \u306e words \u3092\u53d6\u5f97\n// ======================================================\n\nconst words = input.words;\n\nif (!Array.isArray(words) || words.length === 0) {\n throw new Error(\n 'AssemblyAI\u306e\u51fa\u529b\u306b words \u304c\u3042\u308a\u307e\u305b\u3093\u3002\u300cCheck: AssemblyAI Transcript Status\u300d\u306eOutput\u3067 words \u304c\u914d\u5217\u306b\u306a\u3063\u3066\u3044\u308b\u304b\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002'\n );\n}\n\n\n// ======================================================\n// 10. \u52d5\u753bURL\u3092\u53d6\u5f97\n// ======================================================\n\nconst videoUrl =\n input.audio_url ||\n input.video_url ||\n input.videoUrl ||\n input['\u52d5\u753bURL'];\n\nif (!videoUrl) {\n throw new Error(\n '\u52d5\u753bURL\u304c\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3002audio_url / video_url / videoUrl / \u52d5\u753bURL \u306e\u3044\u305a\u308c\u304b\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002'\n );\n}\n\n\n// ======================================================\n// 11. \u8a18\u53f7\u30fb\u6587\u5b57\u5217\u51e6\u7406\u7528\u306e\u8a2d\u5b9a\n// ======================================================\n\nconst punctuationRegex = /[\u3001\u3002,.!?\uff01\uff1f\u300c\u300d\u300e\u300f\uff08\uff09()\\[\\]\u3010\u3011]/g;\n\nfunction normalizeText(text) {\n return String(text || '')\n .replace(punctuationRegex, '')\n .replace(/\\s+/g, '')\n .trim();\n}\n\nfunction cleanDisplayText(text) {\n return String(text || '')\n .replace(punctuationRegex, '')\n .replace(/\\s+/g, '')\n .trim();\n}\n\nfunction keepBreakText(text) {\n return String(text || '')\n .replace(/\\s+/g, '')\n .trim();\n}\n\n\n// ======================================================\n// 12. \u6587\u5b57\u7a2e\u5224\u5b9a\n// ======================================================\n\nfunction isAsciiAlphaNumeric(char) {\n return /[A-Za-z0-9]/.test(char || '');\n}\n\nfunction isJapaneseChar(char) {\n return /[\\u3040-\\u30ff\\u3400-\\u9fff]/.test(char || '');\n}\n\nfunction isKatakanaChar(char) {\n return /[\\u30a0-\\u30ff\u30fc]/.test(char || '');\n}\n\nfunction isDigitChar(char) {\n return /[0-9\uff10-\uff19]/.test(char || '');\n}\n\nfunction isJapaneseNumberUnitChar(char) {\n return /[\u5341\u767e\u5343\u4e07\u5104\u5146]/.test(char || '');\n}\n\nfunction isBadJapaneseContinuation(previous, next) {\n const pair = `${previous || ''}${next || ''}`;\n\n const badPairs = [\n '\u3067\u304d',\n '\u304d\u306a',\n '\u306a\u304f',\n '\u304f\u306a',\n '\u306a\u308a',\n '\u306a\u308b',\n '\u306e\u3067',\n '\u3067\u3059',\n '\u3059\u304c',\n '\u307e\u3057',\n '\u3057\u305f',\n '\u3057\u3066',\n '\u3044\u308b',\n '\u3042\u308a',\n '\u307e\u305b',\n '\u305b\u3093',\n '\u308f\u304b',\n '\u5206\u304b',\n '\u304b\u308a',\n '\u308a\u307e',\n '\u307e\u3059',\n '\u3053\u3068',\n '\u3068\u306f',\n '\u306b\u306f',\n '\u3067\u306f'\n ];\n\n return badPairs.includes(pair);\n}\n\n\n// ======================================================\n// 13. \u5b57\u5e55\u306e\u8abf\u6574\u5024\n// ======================================================\n//\n// \u3053\u3053\u3067\u306f\u3001\u52d5\u753b\u306e\u5411\u304d\u306b\u5408\u308f\u305b\u3066\n// \u300c\u5b57\u5e551\u56de\u3042\u305f\u308a\u306b\u8868\u793a\u3059\u308b\u6587\u5b57\u6570\u300d\u3092\u81ea\u52d5\u3067\u5207\u308a\u66ff\u3048\u308b\u3002\n//\n// \u7406\u7531\uff1a\n// \u6a2a\u9577\u52d5\u753b\u306a\u3089\u3001\u753b\u9762\u306e\u6a2a\u5e45\u304c\u5e83\u3044\u305f\u3081\u5c11\u3057\u9577\u3081\u306e\u5b57\u5e55\u3067\u3082\u8aad\u307f\u3084\u3059\u3044\u3002\n// \u4e00\u65b9\u3067\u3001\u7e26\u9577\u52d5\u753b\u306f\u6a2a\u5e45\u304c\u72ed\u3044\u305f\u3081\u3001\u9577\u3044\u5b57\u5e55\u3092\u51fa\u3059\u3068\n// \u753b\u9762\u4e0a\u3067\u6298\u308a\u8fd4\u3055\u308c\u305f\u308a\u3001\u6587\u5b57\u304c\u8a70\u307e\u3063\u3066\u898b\u3048\u305f\u308a\u3057\u3084\u3059\u3044\u3002\n//\n// \u305d\u306e\u305f\u3081\u3001Fetch: Video Metadata\u30ce\u30fc\u30c9\u306e\n// videoMediaMetadata.width\n// videoMediaMetadata.height\n// \u3092\u78ba\u8a8d\u3057\u3001height \u304c width \u3088\u308a\u5927\u304d\u3044\u5834\u5408\u306f\u300c\u7e26\u52d5\u753b\u300d\u3068\u5224\u65ad\u3059\u308b\u3002\n//\n// \u7e26\u52d5\u753b\u306e\u5834\u5408\uff1a\n// - \u6700\u4f4e\u6587\u5b57\u6570\uff1a6\n// - \u7406\u60f3\u6587\u5b57\u6570\uff1a16\n// - \u901a\u5e38\u4e0a\u9650\uff1a16\n// - \u7d76\u5bfe\u4e0a\u9650\uff1a16\n//\n// \u6a2a\u52d5\u753b\u30fb\u307e\u305f\u306f\u30b5\u30a4\u30ba\u53d6\u5f97\u3067\u304d\u306a\u3044\u5834\u5408\uff1a\n// - \u3053\u308c\u307e\u3067\u901a\u308a\u306e\u8a2d\u5b9a\u3092\u4f7f\u3046\n// - \u6700\u4f4e\u6587\u5b57\u6570\uff1a6\n// - \u7406\u60f3\u6587\u5b57\u6570\uff1a16\n// - \u901a\u5e38\u4e0a\u9650\uff1a22\n// - \u7d76\u5bfe\u4e0a\u9650\uff1a28\n//\n// \u203b const \u3067\u306f\u306a\u304f let \u306b\u3057\u3066\u3044\u308b\u7406\u7531\uff1a\n// \u52d5\u753b\u306e\u5411\u304d\u306b\u3088\u3063\u3066\u3001\u3042\u3068\u304b\u3089\u5024\u3092\u5165\u308c\u66ff\u3048\u308b\u5fc5\u8981\u304c\u3042\u308b\u305f\u3081\u3002\n// ======================================================\n\nconst videoMetadataNodeName = 'Fetch: Video Metadata';\n\nlet videoMetadataInput;\n\ntry {\n videoMetadataInput = $(videoMetadataNodeName).item.json;\n} catch (error) {\n videoMetadataInput = $(videoMetadataNodeName).first().json;\n}\n\nconst videoWidth = Number(videoMetadataInput?.videoMediaMetadata?.width || 0);\nconst videoHeight = Number(videoMetadataInput?.videoMediaMetadata?.height || 0);\n\nconst isPortraitVideo =\n Number.isFinite(videoWidth) &&\n Number.isFinite(videoHeight) &&\n videoWidth > 0 &&\n videoHeight > 0 &&\n videoHeight > videoWidth;\n\nlet minCharsPerSubtitle = 6;\nlet idealCharsPerSubtitle = 16;\nlet maxCharsPerSubtitle = 22;\nlet hardMaxCharsPerSubtitle = 28;\n\nif (isPortraitVideo) {\n minCharsPerSubtitle = 6;\n idealCharsPerSubtitle = 16;\n maxCharsPerSubtitle = 16;\n hardMaxCharsPerSubtitle = 16;\n}\n\n\n// ======================================================\n// 14. \u9014\u4e2d\u3067\u5207\u308a\u305f\u304f\u306a\u3044\u8868\u73fe\n// ======================================================\n\nconst protectedPhrases = [\n '\u7406\u89e3\u3067\u304d\u306a\u3044',\n '\u5168\u304f\u7406\u89e3\u3067\u304d\u306a\u3044',\n '\u3067\u304d\u306a\u3044',\n '\u3067\u304d\u306a\u304f',\n '\u3067\u304d\u306a\u304f\u306a\u308b',\n '\u3067\u304d\u306a\u3044\u3067\u3057\u3087\u3046',\n '\u3067\u304d\u306a\u3044\u3067\u3057\u3087\u3046\u304b\u3089',\n '\u3067\u3057\u3087\u3046\u304b\u3089',\n '\u3067\u3057\u3087\u3046',\n '\u3067\u3057\u3087\u3046\u304b',\n '\u3042\u308a\u307e\u305b\u3093',\n '\u3042\u308a\u307e\u305b\u3093\u3067\u3057\u305f',\n '\u3057\u3066\u3044\u306a\u3044',\n '\u3057\u3066\u3044\u306a\u3044\u3067\u3057\u3087\u3046',\n '\u6301\u3063\u3066\u3044\u306a\u3044',\n '\u6301\u3063\u3066\u3044\u306a\u3044\u3067\u3057\u3087\u3046',\n '\u59cb\u3081\u305f\u306e\u3067\u3059\u304c',\n '\u30c6\u30b9\u30c8\u3092\u59cb\u3081\u305f\u306e\u3067\u3059\u304c',\n '\u5206\u304b\u308a\u307e\u3059',\n '\u308f\u304b\u308a\u307e\u3059',\n '\u898b\u821e\u308f\u308c\u305f\u3053\u3068',\n '\u898b\u821e\u308f\u308c\u305f\u3053\u3068\u304c\u5206\u304b\u308a\u307e\u3059',\n '\u6587\u5b57\u8d77\u3053\u3057',\n '\u975e\u5e38\u306b\u6642\u9593\u304c\u304b\u304b\u3063\u3066\u3044\u307e\u3057\u305f',\n '\u305d\u3093\u306a\u3053\u3068',\n '\u8d77\u3053\u308a\u307e\u305b\u3093',\n '\u30c0\u30c3\u30b7\u30e5\u30dc\u30fc\u30c9',\n '\u30a6\u30a7\u30d6\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3',\n '\u30d5\u30a1\u30a4\u30a2\u30a6\u30a9\u30fc\u30eb',\n '\u30c8\u30e9\u30d5\u30a3\u30c3\u30af',\n '\u6b63\u898f\u306e\u30e6\u30fc\u30b6\u30fc',\n '\u30ea\u30af\u30a8\u30b9\u30c8',\n '\u6bce\u52061\u4e072\u5343\u4ee5\u4e0a',\n '1\u4e072\u5343\u4ee5\u4e0a',\n '\u4f55\u5104\u3082\u306e\u30ea\u30af\u30a8\u30b9\u30c8',\n 'BridgeVoice',\n 'BridgeVoice\u3067\u306f',\n '\u30b9\u30ad\u30eb\u30bb\u30c3\u30c8',\n '\u4e2d\u5c0f\u4f01\u696d',\n '\u5927\u4f01\u696d',\n '\u4e00\u90e8\u306e\u5927\u4f01\u696d',\n '\u30d3\u30b8\u30cd\u30b9\u30de\u30f3',\n 'AI\u526f\u696d',\n '\u4eba\u5de5\u77e5\u80fd',\n '\u52d5\u753b\u7de8\u96c6',\n '\u81ea\u52d5\u5316',\n '\u97f3\u58f0\u8a8d\u8b58',\n '\u751f\u6210AI',\n 'Google\u30b9\u30d7\u30ec\u30c3\u30c9\u30b7\u30fc\u30c8',\n 'Google\u30b9\u30e9\u30a4\u30c9',\n 'Google\u30c9\u30e9\u30a4\u30d6',\n 'n8n',\n 'Creatomate',\n 'AssemblyAI'\n];\n\nfunction isInsideProtectedPhrase(text, index) {\n if (index <= 0 || index >= text.length) {\n return false;\n }\n\n for (const phrase of protectedPhrases) {\n let searchStart = 0;\n\n while (true) {\n const foundIndex = text.indexOf(phrase, searchStart);\n\n if (foundIndex === -1) {\n break;\n }\n\n const phraseStart = foundIndex;\n const phraseEnd = foundIndex + phrase.length;\n\n if (index > phraseStart && index < phraseEnd) {\n return true;\n }\n\n searchStart = foundIndex + 1;\n }\n }\n\n return false;\n}\n\nfunction isBadSplitPosition(text, index) {\n if (index <= 0 || index >= text.length) {\n return false;\n }\n\n const prev = text[index - 1];\n const next = text[index];\n\n const before = cleanDisplayText(text.slice(0, index));\n const after = cleanDisplayText(text.slice(index));\n\n if (\n (before.endsWith('\u30a6\u30a7\u30d6\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3') && after.startsWith('\u30d5\u30a1\u30a4\u30a2\u30a6\u30a9\u30fc\u30eb')) ||\n (before.endsWith('BridgeVoice\u3067\u306f') && after.startsWith('\u305d\u3093\u306a\u3053\u3068')) ||\n (before.endsWith('\u30a2\u30af\u30bb\u30b9') && after.startsWith('\u3067\u304d\u306a\u304f'))\n ) {\n return false;\n }\n\n if (isAsciiAlphaNumeric(prev) && isAsciiAlphaNumeric(next)) {\n return true;\n }\n\n if (isKatakanaChar(prev) && isKatakanaChar(next)) {\n return true;\n }\n\n if (isDigitChar(prev) && isJapaneseNumberUnitChar(next)) {\n return true;\n }\n\n if (isJapaneseNumberUnitChar(prev) && isDigitChar(next)) {\n return true;\n }\n\n if (isBadJapaneseContinuation(prev, next)) {\n return true;\n }\n\n if (isInsideProtectedPhrase(text, index)) {\n return true;\n }\n\n return false;\n}\n\n\n// ======================================================\n// 15. \u5206\u5272\u5019\u88dc\u306e\u8a55\u4fa1\n// ======================================================\n\nconst goodEndingPatterns = [\n '\u3067\u3059',\n '\u307e\u3059',\n '\u3067\u3057\u305f',\n '\u307e\u3057\u305f',\n '\u3067\u3057\u3087\u3046',\n '\u304f\u3060\u3055\u3044',\n '\u3067\u304d\u307e\u3059',\n '\u3067\u304d\u307e\u305b\u3093',\n '\u3042\u308a\u307e\u305b\u3093',\n '\u3057\u3066\u3044\u307e\u3059',\n '\u3057\u3066\u3044\u308b',\n '\u3057\u3066\u3044\u306a\u3044',\n '\u306b\u306a\u308a\u307e\u3059',\n '\u3068\u3044\u3046\u3053\u3068\u3067\u3059',\n '\u3068\u3044\u3046\u3053\u3068',\n '\u3068\u3044\u3046',\n '\u305f\u3081',\n '\u304b\u3089',\n '\u306a\u3089',\n '\u306b\u306f',\n '\u3067\u306f',\n '\u3068\u306f',\n '\u3053\u305d',\n '\u307e\u3067',\n '\u307b\u3069',\n '\u3060\u3051',\n '\u306a\u3069',\n '\u3082\u306e'\n];\n\nconst badEndingPatterns = [\n '\u7406\u89e3\u3067',\n '\u5168\u304f\u7406\u89e3\u3067',\n '\u3067\u304d',\n '\u3067\u304d\u306a',\n '\u3067\u304d\u306a\u3044\u3067',\n '\u3067\u3057',\n '\u306e\u3067',\n '\u3087\u3046',\n '\u3057\u3087\u3046',\n '\u30c0\u30c3\u30b7\u30e5\u30dc',\n '\u30a6\u30a7\u30d6\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u30d5\u30a1',\n '\u30d5\u30a1',\n '\u30d5\u30a1\u30a4',\n '\u30d5\u30a1\u30a4\u30a2',\n '\u30d5\u30a1\u30a4\u30a2\u30a6',\n '\u30a2\u30af\u30bb\u30b9\u3067',\n '1\u4e072',\n '\u6bce\u52061\u4e072',\n '\u30b9\u30ad\u30eb',\n '\u30bb\u30c3\u30c8',\n '\u305d\u3046\u3044\u3063\u305f\u3053\u3068\u306e',\n '\u305d\u3046\u3044\u3063\u305f\u3053\u3068\u3092',\n '\u4e00\u90e8\u306e',\n '\u305d\u306e\u3088\u3046\u306a',\n '\u3069\u308c\u3082',\n '\u5168\u304f'\n];\n\nconst badStartingPatterns = [\n '\u30fc\u30c9',\n '\u304d\u306a\u3044',\n '\u304d\u306a\u304f',\n '\u304d\u306a\u304f\u306a\u308b',\n '\u5343\u4ee5\u4e0a',\n '\u304c\u5206\u304b\u308a\u307e\u3059',\n '\u304c\u308f\u304b\u308a\u307e\u3059',\n '\u3059\u304c',\n '\u306a\u3044\u3067\u3057\u3087\u3046',\n '\u3067\u3057\u3087\u3046\u304b\u3089',\n '\u3057\u3087\u3046\u304b\u3089',\n '\u30bb\u30c3\u30c8',\n '\u4f01\u696d\u3067\u306f',\n '\u90e8\u306e\u5927\u4f01\u696d',\n '\u3046\u3044\u3063\u305f',\n '\u308c\u3082',\n '\u89e3\u3067\u304d\u306a\u3044',\n '\u7406\u89e3\u3067\u304d\u306a\u3044'\n];\n\nconst connectorStartingPatterns = [\n '\u5b9f\u306f',\n '\u7279\u306b',\n '\u7d50\u8ad6\u304b\u3089',\n '\u3067\u306f',\n '\u306a\u305c',\n '\u305d\u306e\u7406\u7531',\n '\u4e00\u3064\u76ee',\n '\u4e8c\u3064\u76ee',\n '\u4e09\u3064\u76ee',\n '\u307e\u305a',\n '\u6b21\u306b',\n '\u3055\u3089\u306b',\n '\u305d\u3057\u3066',\n '\u3057\u304b\u3057',\n '\u4f8b\u3048\u3070',\n '\u4eca\u56de',\n '\u4eca\u3059\u3050',\n '\u3053\u308c\u306b\u3088\u308a',\n '\u3064\u307e\u308a',\n '\u305d\u306e\u305f\u3081',\n '\u5927\u5207\u306a\u306e\u306f',\n '\u3053\u3053\u3067',\n '\u3053\u306e\u3088\u3046\u306b',\n '\u3060\u304b\u3089',\n '\u4e00\u65b9\u3067'\n];\n\nconst preferredStartingPatterns = [\n '\u30c0\u30c3\u30b7\u30e5\u30dc\u30fc\u30c9',\n '\u898b\u821e\u308f\u308c\u305f',\n '\u898b\u821e\u308f\u308c\u305f\u3053\u3068',\n '\u6587\u5b57\u8d77\u3053\u3057',\n 'BridgeVoice\u3067\u306f',\n '\u305d\u3093\u306a\u3053\u3068',\n '\u30d5\u30a1\u30a4\u30a2\u30a6\u30a9\u30fc\u30eb',\n '\u30c8\u30e9\u30d5\u30a3\u30c3\u30af',\n '\u3067\u304d\u306a\u304f',\n '\u3067\u304d\u306a\u304f\u306a\u308b',\n '\u30ea\u30af\u30a8\u30b9\u30c8',\n '\u6628\u65e5',\n '\u5b9f\u969b\u306b\u306f',\n '\u3053\u306e\u653b\u6483',\n '\u6b63\u898f\u306e\u30e6\u30fc\u30b6\u30fc',\n '\u3059\u3050\u306b'\n];\n\nconst preferredEndingPatterns = [\n '\u7c21\u5358\u306a',\n '\u30ea\u30af\u30a8\u30b9\u30c8\u306b',\n '\u59cb\u3081\u305f\u306e\u3067\u3059\u304c',\n 'BridgeVoice\u3067\u306f',\n '\u30a6\u30a7\u30d6\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3',\n '\u30a2\u30af\u30bb\u30b9',\n '1\u4e072\u5343\u4ee5\u4e0a\u306e',\n '\u6bce\u52061\u4e072\u5343\u4ee5\u4e0a\u306e'\n];\n\nfunction endsWithAny(text, patterns) {\n return patterns.some((pattern) => text.endsWith(pattern));\n}\n\nfunction startsWithAny(text, patterns) {\n return patterns.some((pattern) => text.startsWith(pattern));\n}\n\nfunction getSplitScore(text, index) {\n if (index <= 0 || index >= text.length) {\n return -9999;\n }\n\n if (isBadSplitPosition(text, index)) {\n return -9999;\n }\n\n const beforeRaw = text.slice(0, index);\n const afterRaw = text.slice(index);\n\n const before = cleanDisplayText(beforeRaw);\n const after = cleanDisplayText(afterRaw);\n\n if (before.length < minCharsPerSubtitle || after.length < minCharsPerSubtitle) {\n return -200;\n }\n\n if (endsWithAny(before, badEndingPatterns)) {\n return -800;\n }\n\n if (startsWithAny(after, badStartingPatterns)) {\n return -800;\n }\n\n let score = 0;\n\n const prev = text[index - 1];\n const next = text[index];\n\n if (\n prev === '\u3002' ||\n prev === '\uff01' ||\n prev === '\uff1f' ||\n prev === '!' ||\n prev === '?'\n ) {\n score += 120;\n }\n\n if (\n prev === '\u3001' ||\n prev === ',' ||\n prev === ' ' ||\n prev === '\u3000'\n ) {\n score += 100;\n }\n\n if (startsWithAny(after, preferredStartingPatterns)) {\n score += 150;\n }\n\n if (endsWithAny(before, preferredEndingPatterns)) {\n score += 150;\n }\n\n if (startsWithAny(afterRaw, connectorStartingPatterns)) {\n score += 80;\n }\n\n if (endsWithAny(before, goodEndingPatterns)) {\n score += 70;\n }\n\n if (\n prev === '\u306f' ||\n prev === '\u304c' ||\n prev === '\u3092' ||\n prev === '\u306b' ||\n prev === '\u3067' ||\n prev === '\u3078' ||\n prev === '\u3082' ||\n prev === '\u3068' ||\n prev === '\u3084'\n ) {\n score += 35;\n }\n\n if (prev === '\u306e') {\n score += 10;\n }\n\n if (prev === '\u306a' && startsWithAny(after, preferredStartingPatterns)) {\n score += 60;\n }\n\n if (isAsciiAlphaNumeric(prev) && isJapaneseChar(next)) {\n score += 20;\n }\n\n if (isJapaneseChar(prev) && isAsciiAlphaNumeric(next)) {\n score += 20;\n }\n\n const distanceFromIdeal = Math.abs(before.length - idealCharsPerSubtitle);\n score -= distanceFromIdeal * 2;\n\n if (before.length > maxCharsPerSubtitle) {\n score -= 50;\n }\n\n return score;\n}\n\n\n// ======================================================\n// 16. \u5b89\u5168\u3067\u81ea\u7136\u306a\u5206\u5272\u4f4d\u7f6e\u3092\u63a2\u3059\n// ======================================================\n\nfunction findNaturalSplitIndex(text, preferredIndex = idealCharsPerSubtitle) {\n const rawText = String(text || '');\n\n if (cleanDisplayText(rawText).length <= maxCharsPerSubtitle) {\n return rawText.length;\n }\n\n const length = rawText.length;\n let bestIndex = -1;\n let bestScore = -9999;\n\n const searchStart = Math.max(minCharsPerSubtitle, preferredIndex - 10);\n const searchEnd = Math.min(length - minCharsPerSubtitle, hardMaxCharsPerSubtitle + 8);\n\n for (let i = searchStart; i <= searchEnd; i++) {\n const score = getSplitScore(rawText, i);\n\n if (score > bestScore) {\n bestScore = score;\n bestIndex = i;\n }\n }\n\n if (bestIndex !== -1 && bestScore > -100) {\n return bestIndex;\n }\n\n for (\n let i = Math.min(length - minCharsPerSubtitle, maxCharsPerSubtitle);\n i >= minCharsPerSubtitle;\n i--\n ) {\n const score = getSplitScore(rawText, i);\n\n if (score > -100) {\n return i;\n }\n }\n\n for (\n let i = maxCharsPerSubtitle + 1;\n i <= Math.min(length - minCharsPerSubtitle, hardMaxCharsPerSubtitle + 8);\n i++\n ) {\n const score = getSplitScore(rawText, i);\n\n if (score > -100) {\n return i;\n }\n }\n\n let fallbackIndex = Math.min(maxCharsPerSubtitle, length - minCharsPerSubtitle);\n\n while (\n fallbackIndex > minCharsPerSubtitle &&\n isBadSplitPosition(rawText, fallbackIndex)\n ) {\n fallbackIndex--;\n }\n\n if (fallbackIndex <= minCharsPerSubtitle) {\n fallbackIndex = Math.min(maxCharsPerSubtitle, length - minCharsPerSubtitle);\n\n while (\n fallbackIndex < length - minCharsPerSubtitle &&\n isBadSplitPosition(rawText, fallbackIndex)\n ) {\n fallbackIndex++;\n }\n }\n\n return fallbackIndex;\n}\n\n\n// ======================================================\n// 17. LLM\u6574\u5f62\u6e08\u307ftext\u3092\u3001\u53e5\u8aad\u70b9\u30d9\u30fc\u30b9\u3067\u5927\u304d\u304f\u5206\u3051\u308b\n// ======================================================\n\nfunction splitFormattedTextIntoSegments(text) {\n const segments = [];\n let buffer = '';\n\n for (let i = 0; i < text.length; i++) {\n const char = text[i];\n\n buffer += char;\n\n const isStrongBreak =\n char === '\u3002' ||\n char === '\uff01' ||\n char === '\uff1f' ||\n char === '!' ||\n char === '?';\n\n const isWeakBreak =\n char === '\u3001' ||\n char === ',';\n\n const currentText = keepBreakText(buffer);\n const cleanedBuffer = cleanDisplayText(buffer);\n\n if (isStrongBreak) {\n if (cleanedBuffer.length > 0) {\n segments.push({\n text: currentText,\n break_type: 'strong'\n });\n }\n\n buffer = '';\n continue;\n }\n\n if (isWeakBreak && cleanedBuffer.length >= idealCharsPerSubtitle) {\n segments.push({\n text: currentText,\n break_type: 'weak'\n });\n\n buffer = '';\n continue;\n }\n\n if (cleanedBuffer.length >= hardMaxCharsPerSubtitle + 6) {\n const splitIndex = findNaturalSplitIndex(currentText, idealCharsPerSubtitle);\n const before = currentText.slice(0, splitIndex);\n const after = currentText.slice(splitIndex);\n\n if (cleanDisplayText(before).length > 0) {\n segments.push({\n text: before,\n break_type: 'length'\n });\n }\n\n buffer = after;\n continue;\n }\n }\n\n const rest = keepBreakText(buffer);\n\n if (cleanDisplayText(rest).length > 0) {\n segments.push({\n text: rest,\n break_type: 'end'\n });\n }\n\n return segments;\n}\n\n\n// ======================================================\n// 18. \u9577\u3044\u30bb\u30b0\u30e1\u30f3\u30c8\u3092\u81ea\u7136\u306a1\u884c\u5b57\u5e55\u306b\u5206\u5272\u3059\u308b\n// ======================================================\n\nfunction splitLongSegment(segmentText) {\n let current = String(segmentText || '');\n const result = [];\n\n if (cleanDisplayText(current).length <= maxCharsPerSubtitle) {\n const cleaned = cleanDisplayText(current);\n\n if (cleaned) {\n return [cleaned];\n }\n\n return [];\n }\n\n let safetyCounter = 0;\n\n while (cleanDisplayText(current).length > maxCharsPerSubtitle && safetyCounter < 100) {\n safetyCounter++;\n\n const splitIndex = findNaturalSplitIndex(current, idealCharsPerSubtitle);\n\n if (splitIndex <= 0 || splitIndex >= current.length) {\n break;\n }\n\n const before = cleanDisplayText(current.slice(0, splitIndex));\n const after = keepBreakText(current.slice(splitIndex));\n\n if (before.length > 0) {\n result.push(before);\n }\n\n current = after;\n\n if (!current) {\n break;\n }\n }\n\n const rest = cleanDisplayText(current);\n\n if (rest.length > 0) {\n result.push(rest);\n }\n\n return result;\n}\n\n\n// ======================================================\n// 19. \u4e0d\u81ea\u7136\u306a\u5206\u5272\u3092\u5f8c\u51e6\u7406\u3067\u4fee\u5fa9\u3059\u308b\n// ======================================================\n\nfunction shouldMergeBecauseUnnatural(previous, current) {\n if (!previous || !current) {\n return false;\n }\n\n const merged = previous + current;\n\n if (merged.length > hardMaxCharsPerSubtitle) {\n return false;\n }\n\n if (endsWithAny(previous, badEndingPatterns)) {\n return true;\n }\n\n if (startsWithAny(current, badStartingPatterns)) {\n return true;\n }\n\n if (isAsciiAlphaNumeric(previous[previous.length - 1]) && isAsciiAlphaNumeric(current[0])) {\n return true;\n }\n\n if (isKatakanaChar(previous[previous.length - 1]) && isKatakanaChar(current[0])) {\n return true;\n }\n\n if (isDigitChar(previous[previous.length - 1]) && isJapaneseNumberUnitChar(current[0])) {\n return true;\n }\n\n if (previous.endsWith('\u7406\u89e3\u3067') && current.startsWith('\u304d\u306a\u3044')) {\n return true;\n }\n\n if (previous.endsWith('\u3067\u304d') && current.startsWith('\u306a\u3044')) {\n return true;\n }\n\n if (previous.endsWith('\u3067\u304d') && current.startsWith('\u306a\u304f')) {\n return true;\n }\n\n if (previous.endsWith('\u3067') && current.startsWith('\u304d\u306a\u304f')) {\n return true;\n }\n\n if (previous.endsWith('\u306e\u3067') && current.startsWith('\u3059\u304c')) {\n return true;\n }\n\n if (previous.endsWith('\u3053\u3068') && current.startsWith('\u304c\u5206\u304b\u308a\u307e\u3059')) {\n return true;\n }\n\n if (previous.endsWith('\u30c0\u30c3\u30b7\u30e5\u30dc') && current.startsWith('\u30fc\u30c9')) {\n return true;\n }\n\n if (previous.endsWith('\u30d5\u30a1') && current.startsWith('\u30a4\u30a2\u30a6\u30a9\u30fc\u30eb')) {\n return true;\n }\n\n if (previous.endsWith('1\u4e072') && current.startsWith('\u5343\u4ee5\u4e0a')) {\n return true;\n }\n\n if (previous.endsWith('\u30b9\u30ad\u30eb') && current.startsWith('\u30bb\u30c3\u30c8')) {\n return true;\n }\n\n return false;\n}\n\nfunction repairUnnaturalSplits(texts) {\n const repaired = [];\n\n for (const rawText of texts) {\n const text = cleanDisplayText(rawText);\n\n if (!text) {\n continue;\n }\n\n if (repaired.length === 0) {\n repaired.push(text);\n continue;\n }\n\n const previous = repaired[repaired.length - 1];\n\n if (shouldMergeBecauseUnnatural(previous, text)) {\n repaired[repaired.length - 1] = previous + text;\n } else {\n repaired.push(text);\n }\n }\n\n return repaired;\n}\n\n\n// ======================================================\n// 20. \u77ed\u3059\u304e\u308b\u5b57\u5e55\u3092\u81ea\u7136\u306a\u7bc4\u56f2\u3067\u7d50\u5408\u3059\u308b\n// ======================================================\n\nfunction mergeTooShortSegments(texts) {\n const result = [];\n\n for (const rawText of texts) {\n const text = cleanDisplayText(rawText);\n\n if (!text) {\n continue;\n }\n\n if (result.length === 0) {\n result.push(text);\n continue;\n }\n\n const previous = result[result.length - 1];\n const merged = previous + text;\n\n if (text.length < minCharsPerSubtitle && merged.length <= maxCharsPerSubtitle) {\n result[result.length - 1] = merged;\n continue;\n }\n\n if (\n previous.length < minCharsPerSubtitle &&\n merged.length <= maxCharsPerSubtitle\n ) {\n result[result.length - 1] = merged;\n continue;\n }\n\n result.push(text);\n }\n\n return result;\n}\n\n\n// ======================================================\n// 21. 1\u884c\u306b\u53ce\u307e\u308b\u3088\u3046\u306b\u6700\u7d42\u8abf\u6574\u3059\u308b\n// ======================================================\n\nfunction forceOneLineButNatural(texts) {\n const result = [];\n\n for (const rawText of texts) {\n let current = cleanDisplayText(rawText);\n\n if (!current) {\n continue;\n }\n\n if (current.length <= hardMaxCharsPerSubtitle) {\n result.push(current);\n continue;\n }\n\n let safetyCounter = 0;\n\n while (current.length > hardMaxCharsPerSubtitle && safetyCounter < 100) {\n safetyCounter++;\n\n const splitIndex = findNaturalSplitIndex(current, idealCharsPerSubtitle);\n\n if (splitIndex <= 0 || splitIndex >= current.length) {\n break;\n }\n\n const before = cleanDisplayText(current.slice(0, splitIndex));\n const after = cleanDisplayText(current.slice(splitIndex));\n\n if (before.length > 0) {\n result.push(before);\n }\n\n current = after;\n\n if (!current) {\n break;\n }\n }\n\n if (current.length > 0) {\n result.push(current);\n }\n }\n\n return result;\n}\n\n\n// ======================================================\n// 22. \u65e5\u672c\u8a9etext\u304b\u3089\u5b57\u5e55\u30c6\u30ad\u30b9\u30c8\u914d\u5217\u3092\u4f5c\u308b\n// ======================================================\n\nconst rawSegments = splitFormattedTextIntoSegments(textForSubtitle);\n\nlet targetSubtitleTexts = [];\n\nfor (const segment of rawSegments) {\n const parts = splitLongSegment(segment.text);\n\n for (const part of parts) {\n const cleaned = cleanDisplayText(part);\n\n if (cleaned.length > 0) {\n targetSubtitleTexts.push(cleaned);\n }\n }\n}\n\ntargetSubtitleTexts = repairUnnaturalSplits(targetSubtitleTexts);\ntargetSubtitleTexts = mergeTooShortSegments(targetSubtitleTexts);\ntargetSubtitleTexts = forceOneLineButNatural(targetSubtitleTexts);\ntargetSubtitleTexts = repairUnnaturalSplits(targetSubtitleTexts);\ntargetSubtitleTexts = mergeTooShortSegments(targetSubtitleTexts);\n\nif (targetSubtitleTexts.length === 0) {\n throw new Error(\n '\u65e5\u672c\u8a9etext\u304b\u3089\u5b57\u5e55\u533a\u5207\u308a\u3092\u4f5c\u6210\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002text\u306e\u5185\u5bb9\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002'\n );\n}\n\n\n// ======================================================\n// 23. AssemblyAI words \u304b\u3089\u5168\u4f53\u6642\u9593\u3060\u3051\u3092\u53d6\u5f97\u3059\u308b\n// ======================================================\n//\n// \u3053\u3053\u3067\u306f words \u306e text \u306f\u5b57\u5e55\u672c\u6587\u3068\u3057\u3066\u4f7f\u308f\u306a\u3044\u3002\n// words \u306f\u300c\u958b\u59cb\u6642\u523b\u300d\u3068\u300c\u7d42\u4e86\u6642\u523b\u300d\u3092\u53d6\u308b\u305f\u3081\u3060\u3051\u306b\u4f7f\u3046\u3002\n// ======================================================\n\nconst cleanWords = [];\n\nfor (const word of words) {\n const cleanWord = normalizeText(word.text);\n\n if (!cleanWord) {\n continue;\n }\n\n if (\n typeof word.start !== 'number' ||\n typeof word.end !== 'number'\n ) {\n continue;\n }\n\n cleanWords.push({\n text: cleanWord,\n start: word.start,\n end: word.end\n });\n}\n\nif (cleanWords.length === 0) {\n throw new Error(\n 'AssemblyAI\u306ewords\u3092\u30af\u30ea\u30fc\u30f3\u5316\u3057\u305f\u7d50\u679c\u3001\u4f7f\u7528\u3067\u304d\u308b\u5358\u8a9e\u304c\u3042\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002'\n );\n}\n\n\n// ======================================================\n// 24. \u65e5\u672c\u8a9e\u5b57\u5e55\u3092\u52d5\u753b\u5168\u4f53\u306e\u6642\u9593\u306b\u5408\u308f\u305b\u3066\u914d\u7f6e\u3059\u308b\n// ======================================================\n\nconst subtitles = [];\n\nconst totalStartMs = cleanWords[0].start || 0;\nconst totalEndMs = cleanWords[cleanWords.length - 1].end;\n\nif (!Number.isFinite(totalEndMs) || totalEndMs <= totalStartMs) {\n throw new Error(\n `\u5b57\u5e55\u306e\u5168\u4f53\u6642\u9593\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002totalStartMs=${totalStartMs}, totalEndMs=${totalEndMs}`\n );\n}\n\nconst totalDurationMs = totalEndMs - totalStartMs;\n\nconst displayTexts = targetSubtitleTexts\n .map((text) => cleanDisplayText(text))\n .filter((text) => text && text.length > 0);\n\nif (displayTexts.length === 0) {\n throw new Error(\n '\u65e5\u672c\u8a9e\u5b57\u5e55\u30c6\u30ad\u30b9\u30c8\u304c\u7a7a\u3067\u3059\u3002targetSubtitleTexts \u306e\u4e2d\u8eab\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002'\n );\n}\n\nconst totalChars = displayTexts.join('').length;\n\nif (totalChars === 0) {\n throw new Error(\n '\u65e5\u672c\u8a9e\u5b57\u5e55\u306e\u5408\u8a08\u6587\u5b57\u6570\u304c0\u3067\u3059\u3002AI: Polish Japanese Translation\u306e\u51fa\u529b\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002'\n );\n}\n\nconst preferredMinDurationMs = 550;\nconst safeMinDurationMs = Math.min(\n preferredMinDurationMs,\n Math.floor(totalDurationMs / displayTexts.length)\n);\n\nlet currentMs = totalStartMs;\n\nfor (let i = 0; i < displayTexts.length; i++) {\n const displayText = displayTexts[i];\n\n let startMs = currentMs;\n let endMs;\n\n if (i === displayTexts.length - 1) {\n endMs = totalEndMs;\n } else {\n const ratio = displayText.length / totalChars;\n let durationMs = totalDurationMs * ratio;\n\n durationMs = Math.max(durationMs, safeMinDurationMs);\n\n endMs = startMs + durationMs;\n\n const remainingCount = displayTexts.length - i - 1;\n const minimumRemainingMs = remainingCount * safeMinDurationMs;\n\n if (endMs > totalEndMs - minimumRemainingMs) {\n endMs = totalEndMs - minimumRemainingMs;\n }\n }\n\n if (endMs <= startMs) {\n endMs = Math.min(startMs + safeMinDurationMs, totalEndMs);\n }\n\n const durationMs = endMs - startMs;\n\n if (durationMs > 0) {\n subtitles.push({\n time: Number((startMs / 1000).toFixed(2)),\n duration: Number((durationMs / 1000).toFixed(2)),\n value: displayText\n });\n }\n\n currentMs = endMs;\n}\n\n\n// ======================================================\n// 25. \u6700\u5f8c\u306e\u5b57\u5e55\u304c\u5fc5\u305a totalEndMs \u3067\u7d42\u308f\u308b\u3088\u3046\u306b\u5fae\u8abf\u6574\n// ======================================================\n\nif (subtitles.length > 0) {\n const last = subtitles[subtitles.length - 1];\n\n const lastStartMs = Math.round(last.time * 1000);\n const fixedLastDurationMs = totalEndMs - lastStartMs;\n\n if (fixedLastDurationMs > 0) {\n last.duration = Number((fixedLastDurationMs / 1000).toFixed(2));\n }\n}\n\n\n// ======================================================\n// 26. \u5b57\u5e55\u304c\u4f5c\u6210\u3067\u304d\u306a\u304b\u3063\u305f\u5834\u5408\u306f\u30a8\u30e9\u30fc\n// ======================================================\n\nif (subtitles.length === 0) {\n throw new Error(\n '\u5b57\u5e55\u30c7\u30fc\u30bf\u3092\u4f5c\u6210\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002targetSubtitleTexts \u307e\u305f\u306f words \u306e\u4e2d\u8eab\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002'\n );\n}\n\n\n// ======================================================\n// 27. Creatomate\u7528\u306e\u5b57\u5e55\u30c6\u30ad\u30b9\u30c8\u8981\u7d20\u3092\u4f5c\u6210\n// ======================================================\n//\n// 1\u884c\u8868\u793a\u3092\u512a\u5148\u3059\u308b\u3002\n// height\u3092\u5c0f\u3055\u304f\u3057\u3059\u304e\u308b\u3068\u4e0a\u4e0b\u304c\u7aae\u5c48\u306b\u306a\u308b\u305f\u3081\u300110%\u306b\u3059\u308b\u3002\n// \u6587\u5b57\u30b5\u30a4\u30ba\u306f\u5927\u304d\u3059\u304e\u308b\u3068\u6298\u308a\u8fd4\u3057\u3055\u308c\u3084\u3059\u3044\u306e\u30675.5vmin\u306b\u8abf\u6574\u3002\n// ======================================================\n\nconst subtitleElements = subtitles.map((subtitle) => {\n return {\n type: 'text',\n\n time: subtitle.time,\n duration: subtitle.duration,\n text: subtitle.value,\n\n x: '50%',\n y: '82%',\n\n width: '94%',\n height: '10%',\n\n x_alignment: '50%',\n y_alignment: '50%',\n\n fill_color: '#ffffff',\n stroke_color: '#000000',\n stroke_width: '1.1 vmin',\n\n font_family: 'Noto Sans JP',\n font_weight: 700,\n font_size: '5.5 vmin',\n\n background_color: 'rgba(0,0,0,0)',\n\n z_index: 10\n };\n});\n\n\n// ======================================================\n// 28. Creatomate API\u306b\u9001\u308bBody\u3092\u4f5c\u6210\n// ======================================================\n//\n// width / height \u3092\u6307\u5b9a\u3057\u306a\u3044\u3053\u3068\u3067\u3001\u5143\u52d5\u753b\u30b5\u30a4\u30ba\u306e\u307e\u307e\u51fa\u529b\u3059\u308b\u3002\n// ======================================================\n\nconst creatomateBody = {\n output_format: 'mp4',\n\n elements: [\n {\n type: 'video',\n source: videoUrl,\n\n x: '50%',\n y: '50%',\n width: '100%',\n height: '100%',\n\n fit: 'contain',\n\n z_index: 1\n },\n\n ...subtitleElements\n ]\n};\n\n\n// ======================================================\n// 29. \u6b21\u306eHTTP Request\u30ce\u30fc\u30c9\u306b\u6e21\u3059\n// ======================================================\n\nreturn [\n {\n json: {\n video_url: videoUrl,\n\n subtitle_count: subtitles.length,\n subtitles,\n\n subtitle_timing_debug: {\n total_start_ms: totalStartMs,\n total_end_ms: totalEndMs,\n total_duration_sec: Number((totalDurationMs / 1000).toFixed(2)),\n min_chars_per_subtitle: minCharsPerSubtitle,\n ideal_chars_per_subtitle: idealCharsPerSubtitle,\n max_chars_per_subtitle: maxCharsPerSubtitle,\n hard_max_chars_per_subtitle: hardMaxCharsPerSubtitle,\n video_width: videoWidth,\n video_height: videoHeight,\n is_portrait_video: isPortraitVideo,\n last_subtitle_end_sec: Number(\n (\n subtitles[subtitles.length - 1].time +\n subtitles[subtitles.length - 1].duration\n ).toFixed(2)\n )\n },\n\n normalized_text_debug: {\n original_text: input.text,\n normalized_text: textForSubtitle\n },\n\n creatomate_body: creatomateBody\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "63c589da-6961-4eeb-990c-16576b57e2eb",
"name": "Render: Combine Video and Subtitle Timeline",
"type": "n8n-nodes-base.httpRequest",
"position": [
3344,
112
],
"parameters": {
"url": "https://api.creatomate.com/v2/renders",
"method": "POST",
"options": {},
"jsonBody": "={{ $json.creatomate_body }}",
"sendBody": true,
"sendHeaders": true,
"specifyBody": "json",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4.4
},
{
"id": "b3b74bf5-5b32-4cd7-89f1-2dc807368304",
"name": "Check: Transcription Completed?",
"type": "n8n-nodes-base.if",
"position": [
2544,
112
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "f28d7e48-44d1-4e94-a5b5-bbdcfd063186",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.status }}",
"rightValue": "completed"
}
]
}
},
"typeVersion": 2.3
},
{
"id": "2e18d225-cb52-4bff-8aea-4bc7cffd292a",
"name": "AI: Add Natural Punctuation for Subtitle Generation",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"position": [
2768,
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleDriveOAuth2ApigooglePalmApigoogleSheetsTriggerOAuth2ApihttpHeaderAuthslackOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow watches a Google Sheets spreadsheet for new Google Drive video links, sends the video to AssemblyAI for English transcription with Japanese translation, refines the Japanese text with Google Gemini, renders burned-in subtitles via Creatomate, and posts the final…
Source: https://n8n.io/workflows/16139/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Content - Newsletter Agent. Uses formTrigger, chainLlm, outputParserStructured, httpRequest. Event-driven trigger; 91 nodes.
Content - Newsletter Agent. Uses formTrigger, chainLlm, outputParserStructured, httpRequest. Event-driven trigger; 87 nodes.
Content - Write Best Tools In Category Article. Uses formTrigger, httpRequest, slack, chainLlm. Event-driven trigger; 41 nodes.
Automate your lead intake, scoring, and outreach pipeline. This workflow collects leads from forms, enriches and scores them using Relevance AI, routes them by quality, and triggers the right follow-u
This workflow automates the entire process of creating SEO-optimized meta titles and descriptions. It analyzes your webpage, spies on top-ranking competitors for the same keywords, and then uses a mul