Add MiniMax and PinkCherry LTX as pickable video engines, with still or text start.

Shot 1 can run text-to-video; later shots still extend from the last frame. Recommend output is also normalized into shot-script cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 19:38:53 -05:00
co-authored by Cursor
parent 2bebeea526
commit 40cd17f35c
15 changed files with 676 additions and 120 deletions
+24 -10
View File
@@ -1,3 +1,5 @@
import { LTX_NEGATIVE } from '~/utils/videoModels'
let comfyHostOverride = ''
export function setComfyHostOverride(url: string) {
@@ -188,7 +190,12 @@ export function extractPromptFromHistory(entry: unknown) {
const text = String(node.inputs?.prompt || node.inputs?.text || '').trim()
if (!text) continue
if (node?.class_type === 'MiniMaxH3ReferenceToVideo') return text
if (node?.class_type === 'MiniMaxH3ImageToVideo') imageToVideo = text
if (node?.class_type === 'MiniMaxH3ImageToVideo' || node?.class_type === 'MiniMaxH3TextToVideo') imageToVideo = text
if (node?.class_type === 'CLIPTextEncode') {
const title = String((node as { _meta?: { title?: string } })._meta?.title || '')
if (/negative/i.test(title) || text === LTX_NEGATIVE) continue
if (!imageToVideo) imageToVideo = text
}
}
return imageToVideo
}
@@ -200,14 +207,18 @@ export function extractClipMetaFromHistory(entry: unknown) {
if (!graph || typeof graph !== 'object') return meta
for (const node of Object.values(graph as Record<string, { class_type?: string; inputs?: Record<string, unknown> }>)) {
const inputs = node?.inputs || {}
if (node?.class_type === 'MiniMaxH3ImageToVideo') {
if (node?.class_type === 'MiniMaxH3ImageToVideo' || node?.class_type === 'MiniMaxH3TextToVideo' || node?.class_type === 'EmptyLTXVLatentVideo') {
meta.width = Number(inputs.width || meta.width)
meta.height = Number(inputs.height || meta.height)
}
if (node?.class_type === 'BasicScheduler' || node?.class_type === 'PrimitiveInt') {
if (node?.class_type === 'BasicScheduler' || node?.class_type === 'PrimitiveInt' || node?.class_type === 'LTXVScheduler') {
const steps = Number(inputs.steps || 0)
if (steps > 0) meta.steps = steps
}
if (node?.class_type === 'ImageResize+' && typeof inputs.width === 'number' && typeof inputs.height === 'number') {
meta.width = Number(inputs.width)
meta.height = Number(inputs.height)
}
if (node?.class_type === 'RandomNoise' || node?.class_type === 'SamplerMiniMax' || node?.class_type === 'KSampler') {
const seed = Number(inputs.noise_seed ?? inputs.seed ?? 0)
if (seed) meta.seed = seed
@@ -276,13 +287,16 @@ export function comfyFilenamePrefix() {
}
export function isOurComfyVideo(video: { filename: string; subfolder: string }) {
const prefix = comfyFilenamePrefix().replace(/\/$/, '')
const parts = prefix.split('/')
const namePrefix = parts[parts.length - 1]
const sub = parts.length > 1 ? parts.slice(0, -1).join('/') : 'video'
const nameOk = video.filename.startsWith(namePrefix)
const subOk = !video.subfolder || video.subfolder === sub
return nameOk && subOk
const prefixes = [comfyFilenamePrefix(), 'video/LTX23']
return prefixes.some((raw) => {
const prefix = raw.replace(/\/$/, '')
const parts = prefix.split('/')
const namePrefix = parts[parts.length - 1]
const sub = parts.length > 1 ? parts.slice(0, -1).join('/') : 'video'
const nameOk = video.filename.startsWith(namePrefix) || video.filename.startsWith('LTX23')
const subOk = !video.subfolder || video.subfolder === sub
return nameOk && subOk
})
}
export function purgeComfyEnabled() {
+1 -1
View File
@@ -85,7 +85,7 @@ export interface Job {
familyId?: string
parentClipId?: string
parentStillId?: string
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
chainContinuing?: boolean
passes?: { prompt: string }[]
queueId?: string
+4 -4
View File
@@ -38,7 +38,7 @@ export interface LibraryClip {
familyId?: string
parentClipId?: string
chainIndex?: number
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
stillId?: string
sound?: boolean
hideInput?: boolean
@@ -102,7 +102,7 @@ export interface RetryDraft {
samplerName?: string
scheduler?: string
extensions?: QueuedExtension[]
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
}
interface Catalog {
@@ -969,7 +969,7 @@ export async function saveRetryDraft(params: {
samplerName?: string
scheduler?: string
extensions?: QueuedExtension[]
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
}) {
return mutate(params.ownerKey, (catalog) => {
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
@@ -1134,7 +1134,7 @@ export async function saveClip(params: {
familyId?: string
parentClipId?: string
chainIndex?: number
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
stillId?: string
sound?: boolean
}) {
+1 -1
View File
@@ -35,7 +35,7 @@ export interface PendingJob {
referenceImageNames?: string[]
referenceStillIds?: Array<string | null>
useIdentityRefs?: boolean
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
duration?: number
cfg?: number
fps?: number
+7 -2
View File
@@ -1,5 +1,6 @@
import { getSidecarImageHost } from '~/server/utils/imageComfy'
import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow'
import { normalizeShotScript } from '~/utils/parseRecommend'
export type PromptJobStatus = 'queued' | 'running' | 'complete' | 'error'
@@ -206,7 +207,10 @@ function draftUserPrompt(draft: string, mode: string) {
: mode === 'shot-script'
? 'video-shot-script'
: 'video'
return `Studio mode: ${studio}\n\nDraft Prompt:\n${draft}`
const format = studio === 'image-edit'
? 'If this is a single edit, output only the refined prompt. If it needs phases, use ---pass 1--- blocks.'
: 'Output a MiniMax shot list. Each shot starts with "shot N" on its own line, then [SHOT CONFIGURATION], [SUBJECT DIRECTION & ACTION], and [AUDIO CUES], each on its own line. Blank line between shots. No markdown fences. No one-paragraph dump.'
return `Studio mode: ${studio}\n${format}\n\nDraft Prompt:\n${draft}`
}
export async function runPromptRecommend(job: PromptJob, params: {
@@ -227,7 +231,8 @@ export async function runPromptRecommend(job: PromptJob, params: {
const queued = await queuePromptGraph(graph, job.id)
job.message = 'Writing the recommended prompt…'
const text = await waitForPromptText(queued.prompt_id)
job.text = text.trim()
const trimmed = text.trim()
job.text = params.mode === 'edit' ? trimmed : normalizeShotScript(trimmed)
job.status = 'complete'
job.message = 'Recommended prompt ready'
}
+51 -18
View File
@@ -9,6 +9,49 @@ export interface PromptPreset {
builtin?: boolean
}
const SHOT_SCRIPT_FORMAT = `Output ONLY a MiniMax shot list the user can paste into Shot script mode.
Each shot starts with "shot N" on its own line (shot 1, shot 2, shot 3…). Then these three headers, each on its own line:
[SHOT CONFIGURATION]
[SUBJECT DIRECTION & ACTION]
[AUDIO CUES]
Blank line between shots. No preamble, no markdown fences, no commentary.
Do not glue shots into one paragraph. "shot 2" must be a line by itself, never "shot 2:" mid-sentence.
One beat per shot. Later shots: same people, same clothes as now, plus the one new action.
Do not repeat a long identity paragraph on every shot.
Never censor raw or explicit terminology.
Example:
shot 1
[SHOT CONFIGURATION]
- Shot Type: Medium-wide, 50mm, eye-level
- Camera Movement: Slow track from a slight side-front angle
- Lighting: Bright daylight through the trees
- Environment/Scenery: The forest from the still
[SUBJECT DIRECTION & ACTION]
The woman from the still walks the path, taking in the trees.
[AUDIO CUES]
Footsteps on dirt, birds, breeze.
shot 2
[SHOT CONFIGURATION]
- Shot Type: Medium, 50mm, eye-level
- Camera Movement: Hold, then a small pan to her hand
- Lighting: Same daylight
- Environment/Scenery: Same path, same trees
[SUBJECT DIRECTION & ACTION]
She stops and rests her hand on the bark.
[AUDIO CUES]
Soft fabric, breeze, birds.`
const ENHANCE_PROMPT = `You are a prompt-engineering assistant for Flux.2 Klein image edit and MiniMax H3 image-to-video.
Look at the attached still. The person (or subject) in that still is the identity lock. The user draft is the requested change.
@@ -16,37 +59,27 @@ Look at the attached still. The person (or subject) in that still is the identit
Write production-ready prompt text the user can paste into the generator.
Rules:
- Preserve identity from the still: face, age, body type, hair, skin, and distinctive features. Use explicit keep-identity language such as "same person as the reference still", "do not change facial identity", "keep original face, hair, and body".
- Preserve identity from the still: face, age, body type, hair, skin, and distinctive features.
- Describe wardrobe, setting, pose, lighting, camera, and materials concretely.
- Never censor raw or explicit terminology from the draft.
- Do not add a preamble, title, markdown fences, or commentary.
Output format — pick one:
If Studio mode is image-edit:
- Single still/edit: output ONLY the refined prompt.
- Ambitious still edit that should be done in phases (for example wardrobe first, then location): output:
---pass 1---
<prompt for pass 1>
---pass 2---
<prompt for pass 2>
- Motion, multi-beat, or video sequence: first-shot prompt, then later shots with a marker on its own line:
shot 2
<extension prompt>
shot 3
<extension prompt>`
If Studio mode is video or video-shot-script:
${SHOT_SCRIPT_FORMAT}`
const SHOT_SCRIPT_PROMPT = `You are a MiniMax H3 image-to-video shot-script writer.
Analyze the attached still for identity, wardrobe, lighting, and camera. Turn the user's draft into a shot script they can paste into Shot script mode.
Analyze the attached still for wardrobe, lighting, and camera. Turn the user's draft into a shot script.
Format:
- Shot 1 is the text BEFORE any marker (camera, action, audio for the first clip).
- Later shots start with a line that is only: shot 2
- Then shot 3, shot 4, and so on, each on its own line.
- Every shot must keep the same person as the still unless the draft explicitly changes wardrobe or identity.
- Include an [AUDIO CUES] block on shots where sound would help.
- Never censor raw or explicit terminology.
Output ONLY the shot script. No preamble, no markdown fences.`
${SHOT_SCRIPT_FORMAT}`
export const BUILTIN_PRESETS: PromptPreset[] = [
{
@@ -60,7 +93,7 @@ export const BUILTIN_PRESETS: PromptPreset[] = [
id: 'shot-script',
name: 'Shot script',
builtin: true,
description: 'Turn a draft into MiniMax shot 2 / shot 3 extension copy.',
description: 'Turn a draft into a MiniMax shot list with configuration, action, and audio blocks.',
systemPrompt: SHOT_SCRIPT_PROMPT
}
]
+2 -2
View File
@@ -38,7 +38,7 @@ export interface ShotQueue {
fps?: number
samplerName?: string
scheduler?: string
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
sound?: boolean
useIdentityRefs?: boolean
referenceStillIds?: Array<string | null>
@@ -153,7 +153,7 @@ export async function createShotQueue(params: {
fps?: number
samplerName?: string
scheduler?: string
workflow?: 'v1' | 'v2'
workflow?: import('~/utils/videoModels').VideoWorkflowId
sound?: boolean
useIdentityRefs?: boolean
referenceStillIds?: Array<string | null>
+24 -15
View File
@@ -5,6 +5,7 @@ import { pendingFromJob, remainingAfterCurrentShot, writePendingJob } from '~/se
import { emitChainJob, waitForComfySocket, watchComfyJob } from '~/server/utils/watch'
import { comfyFilenamePrefix, queuePrompt, uploadImage } from '~/server/utils/comfy'
import { buildWorkflow } from '~/server/utils/workflow'
import { isLtxWorkflow, isTextToVideo, parseVideoWorkflow, workflowForExtension, type VideoWorkflowId } from '~/utils/videoModels'
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
@@ -23,7 +24,7 @@ export type ChainImage = { filename: string; data: Buffer; type?: string }
type VideoChainParams = {
prompt: string
image: ChainImage
image?: ChainImage | null
width: number
height: number
steps: number
@@ -36,7 +37,7 @@ type VideoChainParams = {
samplerName: string
scheduler: string
extensions: { prompt: string; duration: number }[]
workflow: 'v1' | 'v2'
workflow: VideoWorkflowId
duration: number
useIdentityRefs: boolean
referenceImages: Array<ChainImage | null>
@@ -76,7 +77,8 @@ function paramsFromJob(job: Job): VideoChainParams {
throw new Error('Cannot continue the shot chain: job metadata is missing')
}
const image = loadStillImage(library.ownerKey, library.stillId, library.stillFilename)
if (!image?.data.length) {
const workflow = parseVideoWorkflow(library.workflow)
if (!image?.data.length && !isTextToVideo(workflow)) {
throw new Error('Cannot continue the shot chain: the start still is missing from the library')
}
const referenceImages: Array<ChainImage | null> = [null, null, null, null]
@@ -101,7 +103,7 @@ function paramsFromJob(job: Job): VideoChainParams {
samplerName: library.samplerName || 'res_multistep',
scheduler: library.scheduler || 'simple',
extensions: library.extensions || [],
workflow: library.workflow || 'v1',
workflow,
duration,
useIdentityRefs: library.useIdentityRefs === true,
referenceImages
@@ -121,14 +123,21 @@ export async function queueMiniMax(
const done = watchComfyJob(job, { persist: params.persist })
job.status = 'uploading'
const chainIndex = job.library?.chainIndex || 0
const uploading = params.useIdentityRefs
? (chainIndex > 0 ? 'Uploading identity stills for next shot...' : 'Uploading image to ComfyUI...')
: (chainIndex > 0 ? 'Uploading last frame to ComfyUI...' : 'Uploading image to ComfyUI...')
const queueing = (job.library?.chainIndex || 0) > 0
? 'Queueing extension on MiniMax H3...'
: 'Queueing MiniMax H3 job...'
const graphId = chainIndex > 0 ? workflowForExtension(params.workflow) : params.workflow
const engineName = isLtxWorkflow(graphId) ? 'LTX-2.3' : 'MiniMax H3'
const hasImage = Boolean(params.image?.data?.length)
const uploading = !hasImage
? `Queueing ${engineName} text-to-video…`
: params.useIdentityRefs
? (chainIndex > 0 ? 'Uploading identity stills for next shot...' : 'Uploading image to ComfyUI...')
: (chainIndex > 0 ? 'Uploading last frame to ComfyUI...' : 'Uploading image to ComfyUI...')
const queueing = chainIndex > 0
? `Queueing extension on ${engineName}...`
: `Queueing ${engineName} job...`
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
const uploaded = await uploadImage(params.image, job.id)
const uploaded = hasImage && params.image
? await uploadImage(params.image, job.id)
: { name: '', subfolder: '' }
const referenceNames: string[] = ['', '', '', '']
if (params.useIdentityRefs) {
for (const [index, ref] of (params.referenceImages || []).entries()) {
@@ -161,9 +170,9 @@ export async function queueMiniMax(
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
filenamePrefix: comfyFilenamePrefix(),
sound: params.sound,
workflow: params.workflow,
filenamePrefix: isLtxWorkflow(graphId) ? 'video/LTX23' : comfyFilenamePrefix(),
sound: params.sound && !isLtxWorkflow(graphId),
workflow: graphId,
duration: params.duration,
useIdentityRefs: params.useIdentityRefs,
referenceImageNames: params.useIdentityRefs ? referenceNames : []
@@ -317,7 +326,7 @@ export async function continueQueuedExtensions(
samplerName: params.samplerName,
scheduler: params.scheduler,
persist,
workflow: params.workflow,
workflow: workflowForExtension(params.workflow),
duration: ext.duration,
useIdentityRefs: params.useIdentityRefs,
referenceImages: params.useIdentityRefs ? params.referenceImages : []
+89 -11
View File
@@ -1,9 +1,18 @@
// Nitro bundles these JSON graphs into the production server output.
import workflowV1 from '../assets/workflow_minimax_video.json'
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
import workflowLtx from '../assets/workflow_ltx_video.json'
import { buildIdentityPrompt } from '~/utils/identityPrompt'
import {
isLtxWorkflow,
isTextToVideo,
LTX_NEGATIVE,
parseVideoWorkflow,
type VideoWorkflowId
} from '~/utils/videoModels'
export type VideoWorkflowId = 'v1' | 'v2'
export type { VideoWorkflowId }
export { parseVideoWorkflow }
export interface GenerateParams {
prompt: string
@@ -31,6 +40,7 @@ type WorkflowGraph = Record<string, WorkflowNode>
const PROMPT_CLASSES = new Set([
'MiniMaxH3ImageToVideo',
'MiniMaxH3TextToVideo',
'MiniMaxH3ReferenceToVideo',
'CLIPTextEncode',
'CLIPTextEncodeQwen3VL',
@@ -58,16 +68,15 @@ function titleOf(node: WorkflowNode) {
}
function templateFor(id: VideoWorkflowId) {
if (isLtxWorkflow(id)) return workflowLtx as WorkflowGraph
return (id === 'v2' ? workflowV2 : workflowV1) as WorkflowGraph
}
export function parseVideoWorkflow(raw: unknown): VideoWorkflowId {
return String(raw || '').trim() === 'v2' ? 'v2' : 'v1'
}
export function buildWorkflow(params: GenerateParams) {
const version = params.workflow === 'v2' ? 'v2' : 'v1'
const graph = structuredClone(templateFor(version))
const version = parseVideoWorkflow(params.workflow)
if (isLtxWorkflow(version)) return buildLtxWorkflow(params, version)
const graph = structuredClone(templateFor(version === 't2v' ? 'v1' : version === 'v2' ? 'v2' : 'v1'))
const width = snap32(params.width)
const height = snap32(params.height)
const refs = Array.from({ length: 4 }, (_, index) => String(params.referenceImageNames?.[index] || '').trim())
@@ -155,7 +164,7 @@ export function buildWorkflow(params: GenerateParams) {
}
}
if (version === 'v1' && graph['128']?.class_type === 'ImageScaleToTotalPixels') {
if (version === 'v1' && !isTextToVideo(version) && graph['128']?.class_type === 'ImageScaleToTotalPixels') {
graph['128'] = {
class_type: 'ImageScale',
inputs: {
@@ -169,7 +178,7 @@ export function buildWorkflow(params: GenerateParams) {
}
}
if (version === 'v1') {
if (version === 'v1' || version === 't2v') {
graph['105:cfg'] = {
class_type: 'FluxGuidance',
inputs: {
@@ -191,10 +200,11 @@ export function buildWorkflow(params: GenerateParams) {
graph[qualityId].inputs.value = params.turbo ? 20 : params.steps
}
if (turboId && graph[turboId]?.class_type === 'PrimitiveInt') {
graph[turboId].inputs.value = params.turbo ? params.steps : (params.workflow === 'v2' ? 6 : 8)
graph[turboId].inputs.value = params.turbo ? params.steps : (version === 'v2' ? 6 : 8)
}
}
if (version === 't2v') applyMinimaxTextToVideo(graph, params, width, height)
if (version === 'v2') applyV2IdentityPath(graph, params, useRefs, refs, graphPrompt)
if (params.sound === false) {
@@ -203,6 +213,7 @@ export function buildWorkflow(params: GenerateParams) {
if (graph['105:91']?.inputs) delete graph['105:91'].inputs.audio
delete graph['163']
if (graph['172']?.inputs) delete graph['172'].inputs.audio
if (graph['105:104']?.inputs) delete graph['105:104'].inputs.audio_vae
}
return graph
@@ -210,10 +221,76 @@ export function buildWorkflow(params: GenerateParams) {
function promptForNode(classType: string, useRefs: boolean, actionPrompt: string, graphPrompt: string) {
if (classType === 'MiniMaxH3ImageToVideo') return useRefs ? '' : actionPrompt
if (classType === 'MiniMaxH3TextToVideo') return actionPrompt
if (classType === 'MiniMaxH3ReferenceToVideo') return useRefs ? graphPrompt : actionPrompt
return graphPrompt
}
function applyMinimaxTextToVideo(graph: WorkflowGraph, params: GenerateParams, width: number, height: number) {
const node = graph['105:104']
if (node) {
node.class_type = 'MiniMaxH3TextToVideo'
node._meta = { title: 'MiniMax H3 Text to Video' }
delete node.inputs.first_frame
node.inputs.prompt = params.prompt
node.inputs.width = width
node.inputs.height = height
if (params.sound !== false && graph['105:24']) node.inputs.audio_vae = ['105:24', 0]
else delete node.inputs.audio_vae
}
delete graph['114']
delete graph['119']
delete graph['120']
delete graph['127']
delete graph['128']
}
function buildLtxWorkflow(params: GenerateParams, version: VideoWorkflowId) {
const graph = structuredClone(workflowLtx as WorkflowGraph)
const width = snap32(params.width)
const height = snap32(params.height)
const textToVideo = isTextToVideo(version)
if (graph['4']?.inputs) graph['4'].inputs.image = params.imageName
if (graph['5']?.inputs) {
graph['5'].inputs.width = width
graph['5'].inputs.height = height
}
if (graph['6']?.inputs) graph['6'].inputs.text = params.prompt
if (graph['7']?.inputs) graph['7'].inputs.text = LTX_NEGATIVE
if (graph['8']?.inputs && typeof graph['8'].inputs.length === 'number') graph['8'].inputs.length = params.length
if (graph['9']?.inputs) graph['9'].inputs.frame_rate = params.fps
if (graph['10']?.inputs) graph['10'].inputs.cfg = params.cfg
if (graph['11']?.inputs) graph['11'].inputs.sampler_name = params.samplerName || 'euler'
if (graph['12']?.inputs) graph['12'].inputs.steps = params.steps
if (graph['13']?.inputs) graph['13'].inputs.noise_seed = params.seed
if (graph['16']?.inputs) {
graph['16'].inputs.frame_rate = params.fps
graph['16'].inputs.filename_prefix = params.filenamePrefix || 'video/LTX23'
}
if (graph['21']?.inputs) graph['21'].inputs.strength_model = 1
if (textToVideo) {
delete graph['4']
delete graph['5']
graph['8'] = {
class_type: 'EmptyLTXVLatentVideo',
inputs: {
width,
height,
length: params.length,
batch_size: 1
},
_meta: { title: 'Empty LTX latent' }
}
if (graph['9']?.inputs) {
graph['9'].inputs.positive = ['6', 0]
graph['9'].inputs.negative = ['7', 0]
}
if (graph['12']?.inputs) graph['12'].inputs.latent = ['8', 0]
if (graph['14']?.inputs) graph['14'].inputs.latent_image = ['8', 0]
}
return graph
}
function applyV2IdentityPath(
graph: WorkflowGraph,
params: GenerateParams,
@@ -304,9 +381,10 @@ function labelsFrom(template: WorkflowGraph) {
export const NODE_LABELS: Record<string, string> = {
...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)),
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
...Object.fromEntries(labelsFrom(workflowLtx as WorkflowGraph)),
...LABEL_OVERRIDES
}
export function isEncodingNode(node: string) {
return node === '92' || node === '105:91' || node === '172' || /encoding|saving mp4|create video/i.test(NODE_LABELS[node] || '')
return node === '92' || node === '16' || node === '105:91' || node === '172' || /encoding|saving mp4|create video|save mp4/i.test(NODE_LABELS[node] || '')
}