@mevdragon/vidfarm-devcli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/SKILL.director.md +123 -0
- package/SKILL.platform.md +15 -2
- package/dist/src/account-pages-legacy.js +15 -10
- package/dist/src/app.js +550 -184
- package/dist/src/cli.js +397 -14
- package/dist/src/config.js +5 -0
- package/dist/src/devcli/clips.js +312 -49
- package/dist/src/devcli/speech.js +175 -0
- package/dist/src/editor-chat.js +3 -2
- package/dist/src/frontend/homepage-view.js +0 -5
- package/dist/src/page-shell.js +47 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +239 -0
- package/dist/src/services/billing.js +3 -0
- package/dist/src/services/clip-curation/ffmpeg.js +59 -17
- package/dist/src/services/clip-curation/gemini.js +72 -23
- package/dist/src/services/clip-curation/hunt.js +332 -0
- package/dist/src/services/clip-curation/index.js +3 -1
- package/dist/src/services/clip-curation/local-agent.js +247 -0
- package/dist/src/services/clip-curation/refine.js +7 -29
- package/dist/src/services/clip-curation/scan.js +37 -10
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +74 -0
- package/dist/src/services/providers.js +30 -429
- package/dist/src/services/serverless-records.js +9 -2
- package/dist/src/services/speech.js +467 -0
- package/dist/src/services/storage.js +16 -1
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +17 -17
package/dist/src/editor-chat.js
CHANGED
|
@@ -34,14 +34,15 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
34
34
|
"Execute REST calls sequentially. Make one API call, inspect the response, then decide the next step.",
|
|
35
35
|
"A queued async POST job is enough inspection for that request. Do not poll it in the same response; continue launching any remaining requested outputs, then summarize the queued jobs.",
|
|
36
36
|
"Primitive routes under /api/v1/primitives are first-class allowed routes in this editor, even when the primitive is not specific to the current template.",
|
|
37
|
-
"Never say you cannot use primitive image generation, image edit, inpaint, HTML render, AI video generation, or video render routes directly from this editor chat; use the http_request tool when the requested primitive route exists.",
|
|
37
|
+
"Never say you cannot use primitive image generation, image edit, inpaint, HTML render, AI video generation, text-to-speech, speech transcription, or video render routes directly from this editor chat; use the http_request tool when the requested primitive route exists.",
|
|
38
38
|
"All primitive POST routes require the same outer JSON envelope: top-level tracer, top-level payload containing the primitive-specific fields, and optional webhook_url.",
|
|
39
39
|
"When the user explicitly asks to use primitives or timeline rendering, do not redirect them to old per-template operation routes unless the requested primitive route cannot accept the available inputs.",
|
|
40
40
|
"If the user asks to create, generate, or make a new image similar to an attached image, call POST /api/v1/primitives/images/generate with body { tracer, payload: { prompt, prompt_attachments: [exact attachment URL] } }.",
|
|
41
41
|
"Use POST /api/v1/primitives/images/edit only when the user wants to revise the attached/source image itself; its body is { tracer, payload: { source_image_url, instruction, reference_attachments? } }. Use POST /api/v1/primitives/images/generate when they want a new image inspired by or similar to the attachment.",
|
|
42
42
|
"If the user asks to modify an attached image and the message includes an attachment URL, call POST /api/v1/primitives/images/edit with source_image_url inside payload set to that exact URL and instruction inside payload set to the requested edit.",
|
|
43
43
|
"If the user asks to generate a new AI video from text or reference images, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, input_references?, duration? } }. AI video duration is seconds; use duration: 4 for a 4-second video and never use duration_ms on /videos/generate. input_references must be direct image asset URLs, not webpage or social post URLs. Use frame_images for exact first/last-frame image-to-video.",
|
|
44
|
-
"If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video.",
|
|
44
|
+
"If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or a YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver), aspect crops every clip, and avoid_text prefers text-free scenes via scene SELECTION (never caption removal on the source). The hunt is async (202 + scan_id): poll GET /clips/scan/:scanId, then browse GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys. To erase captions from a finished HUNTED clip, pass that short mp4 to /videos/remove-captions afterwards.",
|
|
45
|
+
"SPEECH PRIMITIVES (TTS/STT) are first-class here. VOICEOVER / NARRATION: when the user wants a script, hook, caption, or dub read aloud as AI audio, call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? } } — instructions is a free-form voice-STYLE prompt (tone, pacing, accent, emotion, persona; e.g. 'calm warm bedtime narrator' or 'excited sports announcer, fast paced'), and voice picks a provider preset (OpenAI: alloy/ash/coral/...; Gemini: Kore/Puck/Charon/...). The finished job returns a durable audio URL — place it on the timeline with editor_action add_layer (kind=audio, src=that URL), typically starting where the narration should begin. TRANSCRIPTION: when the user wants a transcript, subtitles/captions from speech, a translation source, or to know who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language?, prompt? } } — source_url may be a VIDEO or AUDIO URL (video audio is demuxed automatically; pick the fork's source via GET /api/v1/compositions/:forkId/remove-video-captions first when transcribing the current composition). One job returns BOTH formats: a simple subtitle version (plain transcript text + timed SRT cues, stored as transcript.txt/transcript.srt) and an advanced multi-speaker version (speaker-attributed timed segments in output.segments and transcript.json) for multi-narration sources like podcasts and interviews. diarize defaults to true and speaker attribution needs a Gemini key — openai/openrouter keys degrade to a plain single-speaker transcript. For transcript-grounded caption work on THIS fork, the read-only video_context tool may already contain the decompose-time transcript; prefer it before running a new billable-key transcription, and use /audio/transcribe for other URLs, multi-speaker attribution, or SRT output.",
|
|
45
46
|
"Brainstorm primitives are also available directly here. If the user explicitly asks for hooks, angles, awareness-stage advice, a cold-start strategy questionnaire, or product-placement opportunities in a video, call the matching brainstorm primitive immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart with body { tracer, payload: { user_message }, webhook_url? } when the user is clueless and needs foundational strategy questions. Use /brainstorm/awareness_stages with body { tracer, payload: { offer_description }, webhook_url? } when they are unsure what type of ads to make. Use /brainstorm/angles with body { tracer, payload: { offer_description, problem_awareness, solution_awareness }, webhook_url? } when they want persuasive ad angles; problem_awareness must be problem_unaware or problem_aware, and solution_awareness must be solution_unaware or solution_aware. Use /brainstorm/hooks with body { tracer, payload: { offer_description }, webhook_url? } when they are stuck on openings and want many hooks.",
|
|
46
47
|
"Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source (the ORIGINAL uploaded video vs the DECOMPOSED, caption-free video), then pass that URL. offer_description is the user's product/offer. This primitive watches the video and returns timestamped, native placement opportunities. It prefers a Gemini key for video understanding.",
|
|
47
48
|
"BEFORE calling /brainstorm/product_placement for the CURRENT composition, first call the product_placement_context tool (or GET /api/v1/compositions/:forkId/product-placement.json). Decompose already scouted product-AGNOSTIC placement surfaces for this fork — real, grounded moments (timestamp, surface_type, surface_note, best_for, why_it_works). Use those surfaces to ground your answer and to seed the primitive: fold the strongest surfaces into offer_description (for example append 'Known native placement surfaces: 0:04 empty desk prop; 0:12 phone screen swap') so the product-specific pass builds on grounded moments instead of re-watching blind. If the user just wants a quick conversational take on where their product could go, the surfaces from product_placement_context are often enough to answer directly without the billable primitive.",
|
|
@@ -11,9 +11,6 @@ function LibraryIcon() {
|
|
|
11
11
|
function CalendarIcon() {
|
|
12
12
|
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "10", cy: "10", r: "7.2" }), _jsx("path", { d: "M10 6.3v4.1l2.8 1.7" })] });
|
|
13
13
|
}
|
|
14
|
-
function ClipsIcon() {
|
|
15
|
-
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "3", y: "4.5", width: "14", height: "11", rx: "1.5" }), _jsx("path", { d: "M3 8h14" }), _jsx("path", { d: "M6.2 4.7 4.6 8" }), _jsx("path", { d: "M9.6 4.7 8 8" }), _jsx("path", { d: "M13 4.7 11.4 8" })] });
|
|
16
|
-
}
|
|
17
14
|
function ChatIcon() {
|
|
18
15
|
return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M4.2 5.2a2 2 0 0 1 2-2h7.6a2 2 0 0 1 2 2v5.6a2 2 0 0 1-2 2H9.2l-4.1 3.1v-3.1a2 2 0 0 1-.9-1.7Z" }), _jsx("path", { d: "M7.3 7.1h5.4" }), _jsx("path", { d: "M7.3 9.6h3.7" })] });
|
|
19
16
|
}
|
|
@@ -58,13 +55,11 @@ function PrimaryNav({ account }) {
|
|
|
58
55
|
? [
|
|
59
56
|
{ id: "discover", href: withAccountQuery("/discover", account.userId), label: "Discover", icon: _jsx(DiscoverIcon, {}) },
|
|
60
57
|
{ id: "library", href: withAccountQuery("/library", account.userId), label: "Library", icon: _jsx(LibraryIcon, {}) },
|
|
61
|
-
{ id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
|
|
62
58
|
{ id: "calendar", href: withAccountQuery("/calendar", account.userId), label: "Calendar", icon: _jsx(CalendarIcon, {}) },
|
|
63
59
|
{ id: "settings", href: withAccountQuery("/settings", account.userId), label: "Settings", icon: _jsx(SettingsIcon, {}) }
|
|
64
60
|
]
|
|
65
61
|
: [
|
|
66
62
|
{ id: "discover", href: "/discover", label: "Discover", icon: _jsx(DiscoverIcon, {}) },
|
|
67
|
-
{ id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
|
|
68
63
|
{ id: "login", href: "/login", label: "Login", icon: _jsx(SettingsIcon, {}) }
|
|
69
64
|
];
|
|
70
65
|
const menuItems = account.isLoggedIn
|
package/dist/src/page-shell.js
CHANGED
|
@@ -756,6 +756,53 @@ export function renderPageShell(input) {
|
|
|
756
756
|
color: var(--muted);
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
.view-toggle {
|
|
760
|
+
display: inline-flex;
|
|
761
|
+
flex: 0 0 auto;
|
|
762
|
+
gap: 4px;
|
|
763
|
+
padding: 3px;
|
|
764
|
+
border: 1px solid rgba(191, 164, 109, 0.28);
|
|
765
|
+
border-radius: 999px;
|
|
766
|
+
background: linear-gradient(180deg, rgba(247, 241, 230, 0.94), rgba(244, 236, 223, 0.96));
|
|
767
|
+
box-shadow:
|
|
768
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.88),
|
|
769
|
+
0 8px 18px rgba(138, 102, 32, 0.08);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.view-toggle-tab {
|
|
773
|
+
display: inline-flex;
|
|
774
|
+
align-items: center;
|
|
775
|
+
justify-content: center;
|
|
776
|
+
min-height: 38px;
|
|
777
|
+
padding: 0 18px;
|
|
778
|
+
border: 1px solid transparent;
|
|
779
|
+
border-radius: 999px;
|
|
780
|
+
background: transparent;
|
|
781
|
+
color: #6f6451;
|
|
782
|
+
text-decoration: none;
|
|
783
|
+
font-size: 0.9rem;
|
|
784
|
+
font-weight: 700;
|
|
785
|
+
font-family: inherit;
|
|
786
|
+
white-space: nowrap;
|
|
787
|
+
cursor: pointer;
|
|
788
|
+
box-shadow: none;
|
|
789
|
+
transition: background 160ms ease, color 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
@media (hover: hover) {
|
|
793
|
+
.view-toggle-tab:hover {
|
|
794
|
+
transform: none;
|
|
795
|
+
color: var(--ink);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.view-toggle-tab.is-active {
|
|
800
|
+
border-color: rgba(232, 223, 204, 0.9);
|
|
801
|
+
background: rgba(255, 252, 247, 0.96);
|
|
802
|
+
color: var(--ink);
|
|
803
|
+
box-shadow: 0 8px 20px rgba(101, 73, 18, 0.08);
|
|
804
|
+
}
|
|
805
|
+
|
|
759
806
|
.button,
|
|
760
807
|
button,
|
|
761
808
|
.link-button,
|
|
@@ -181,6 +181,22 @@ export function createPrimitiveJobContext(input) {
|
|
|
181
181
|
workerId: input.workerId,
|
|
182
182
|
...request
|
|
183
183
|
});
|
|
184
|
+
},
|
|
185
|
+
async generateSpeech(request) {
|
|
186
|
+
return input.providers.generateSpeech({
|
|
187
|
+
customerId: input.customer.id,
|
|
188
|
+
jobId: input.job.id,
|
|
189
|
+
workerId: input.workerId,
|
|
190
|
+
...request
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
async transcribeSpeech(request) {
|
|
194
|
+
return input.providers.transcribeSpeech({
|
|
195
|
+
customerId: input.customer.id,
|
|
196
|
+
jobId: input.job.id,
|
|
197
|
+
workerId: input.workerId,
|
|
198
|
+
...request
|
|
199
|
+
});
|
|
184
200
|
}
|
|
185
201
|
},
|
|
186
202
|
hyperframes: {
|
|
@@ -7,6 +7,7 @@ import { config } from "./config.js";
|
|
|
7
7
|
import { definePrimitive } from "./primitive-sdk.js";
|
|
8
8
|
import { PrimitiveMediaLambdaService } from "./services/primitive-media-lambda.js";
|
|
9
9
|
import { estimateGhostcutCostUsd, isGhostcutConfigured, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
10
|
+
import { buildSrtFromSegments, defaultSpeechModelFor } from "./services/speech.js";
|
|
10
11
|
import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
|
|
11
12
|
const imageProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
12
13
|
const videoProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
@@ -358,6 +359,47 @@ const trimAudioPayloadSchema = z.object({
|
|
|
358
359
|
message: "Either duration_ms or end_ms is required.",
|
|
359
360
|
path: ["duration_ms"]
|
|
360
361
|
});
|
|
362
|
+
const speechProviderSchema = z.enum(["openai", "gemini", "openrouter"]);
|
|
363
|
+
const ttsPayloadSchema = z.preprocess((raw) => {
|
|
364
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
365
|
+
return raw;
|
|
366
|
+
const payload = raw;
|
|
367
|
+
return {
|
|
368
|
+
...payload,
|
|
369
|
+
text: payload.text ?? payload.input ?? payload.script,
|
|
370
|
+
instructions: payload.instructions ?? payload.style ?? payload.style_instructions ?? payload.voice_style
|
|
371
|
+
};
|
|
372
|
+
}, z.object({
|
|
373
|
+
provider: speechProviderSchema.optional(),
|
|
374
|
+
model: z.string().min(1).optional(),
|
|
375
|
+
text: z.string().min(1).max(8000),
|
|
376
|
+
voice: z.string().min(1).max(120).optional(),
|
|
377
|
+
instructions: z.string().min(1).max(4000).optional(),
|
|
378
|
+
output_format: z.enum(["mp3", "wav"]).default("mp3")
|
|
379
|
+
}));
|
|
380
|
+
const sttPayloadSchema = z.preprocess((raw) => {
|
|
381
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
382
|
+
return raw;
|
|
383
|
+
const payload = raw;
|
|
384
|
+
return {
|
|
385
|
+
...payload,
|
|
386
|
+
source_url: payload.source_url ??
|
|
387
|
+
payload.source_video_url ??
|
|
388
|
+
payload.source_audio_url ??
|
|
389
|
+
payload.source_media_url ??
|
|
390
|
+
payload.video_url ??
|
|
391
|
+
payload.audio_url ??
|
|
392
|
+
payload.url
|
|
393
|
+
};
|
|
394
|
+
}, z.object({
|
|
395
|
+
provider: speechProviderSchema.optional(),
|
|
396
|
+
model: z.string().min(1).optional(),
|
|
397
|
+
source_url: mediaSourceUrlSchema,
|
|
398
|
+
media_type: z.enum(["auto", "video", "audio"]).default("auto"),
|
|
399
|
+
diarize: z.boolean().default(true),
|
|
400
|
+
language: z.string().min(2).max(16).optional(),
|
|
401
|
+
prompt: z.string().min(1).max(2000).optional()
|
|
402
|
+
}));
|
|
361
403
|
const probeVideoPayloadSchema = z.object({
|
|
362
404
|
source_video_url: mediaSourceUrlSchema
|
|
363
405
|
});
|
|
@@ -508,6 +550,14 @@ const PRIMITIVE_BRAINSTORM_ANGLES_ID = "primitive:brainstorm_angles";
|
|
|
508
550
|
const PRIMITIVE_BRAINSTORM_COLDSTART_ID = "primitive:brainstorm_coldstart";
|
|
509
551
|
const PRIMITIVE_BRAINSTORM_PRODUCT_PLACEMENT_ID = "primitive:brainstorm_product_placement";
|
|
510
552
|
const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
|
|
553
|
+
const PRIMITIVE_TTS_ID = "primitive:tts";
|
|
554
|
+
const PRIMITIVE_STT_ID = "primitive:stt";
|
|
555
|
+
// Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
|
|
556
|
+
// that is roughly 40 minutes of speech.
|
|
557
|
+
const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
558
|
+
// Keep job results comfortably under the DynamoDB item limit: transcripts
|
|
559
|
+
// longer than this inline budget stay artifact-only (transcript.json).
|
|
560
|
+
const MAX_INLINE_TRANSCRIPT_CHARS = 20_000;
|
|
511
561
|
const primitiveMediaService = new PrimitiveMediaLambdaService();
|
|
512
562
|
const hooksReferenceText = readPrimitiveAsset("SELLING_WITH_HOOKS.md");
|
|
513
563
|
const awarenessReferenceText = readPrimitiveAsset("SELLING_AWARENESS_STAGES.md");
|
|
@@ -1403,6 +1453,13 @@ const videoRemoveCaptionsPrimitive = definePrimitive({
|
|
|
1403
1453
|
}
|
|
1404
1454
|
const probedDurationSeconds = Number(probeMetadata.durationSeconds ?? 0);
|
|
1405
1455
|
const durationSeconds = Number.isFinite(probedDurationSeconds) && probedDurationSeconds > 0 ? probedDurationSeconds : 30;
|
|
1456
|
+
// GhostCut is a per-FINISHED-CLIP tool, never a long-form pass. Clip
|
|
1457
|
+
// hunting handles "no captions" as a scene-selection filter (avoid_text);
|
|
1458
|
+
// after the hunt, apply caption removal to individual finished clips.
|
|
1459
|
+
if (config.GHOSTCUT_MAX_DURATION_SEC > 0 && durationSeconds > config.GHOSTCUT_MAX_DURATION_SEC) {
|
|
1460
|
+
throw new Error(`Caption removal is limited to videos up to ${Math.round(config.GHOSTCUT_MAX_DURATION_SEC / 60)} minutes (this source is ${Math.round(durationSeconds / 60)} min). ` +
|
|
1461
|
+
`For long-form video, hunt clips first (POST /clips/scan with avoid_text/no-captions guidance selects text-free scenes), then remove captions from the finished clips you keep.`);
|
|
1462
|
+
}
|
|
1406
1463
|
const cost = estimateGhostcutCostUsd(durationSeconds);
|
|
1407
1464
|
ctx.logger.progress(0.12, "Submitting caption-removal task", {
|
|
1408
1465
|
durationSeconds: Number(durationSeconds.toFixed(3)),
|
|
@@ -1559,6 +1616,181 @@ const audioTrimPrimitive = definePrimitive({
|
|
|
1559
1616
|
}
|
|
1560
1617
|
}
|
|
1561
1618
|
});
|
|
1619
|
+
const ttsPrimitive = definePrimitive({
|
|
1620
|
+
id: PRIMITIVE_TTS_ID,
|
|
1621
|
+
kind: "tts",
|
|
1622
|
+
operations: {
|
|
1623
|
+
run: {
|
|
1624
|
+
description: "Generate spoken narration audio from text (TTS) with a promptable voice style, persisted as a reusable platform audio URL.",
|
|
1625
|
+
inputSchema: ttsPayloadSchema,
|
|
1626
|
+
workflow: "run"
|
|
1627
|
+
}
|
|
1628
|
+
},
|
|
1629
|
+
jobs: {
|
|
1630
|
+
async run(ctx, input) {
|
|
1631
|
+
const payload = ttsPayloadSchema.parse(input);
|
|
1632
|
+
ctx.logger.progress(0.12, "Generating speech audio");
|
|
1633
|
+
const provider = payload.provider ?? "openai";
|
|
1634
|
+
const model = payload.model ?? defaultSpeechModelFor("tts", provider) ?? "";
|
|
1635
|
+
const generated = await ctx.providers.generateSpeech({
|
|
1636
|
+
provider,
|
|
1637
|
+
model,
|
|
1638
|
+
text: payload.text,
|
|
1639
|
+
voice: payload.voice,
|
|
1640
|
+
instructions: payload.instructions,
|
|
1641
|
+
responseFormat: payload.output_format
|
|
1642
|
+
});
|
|
1643
|
+
// Gemini TTS answers in wav regardless of the requested format; name the
|
|
1644
|
+
// artifact after what actually came back.
|
|
1645
|
+
const extension = generated.contentType.includes("wav") ? "wav" : "mp3";
|
|
1646
|
+
const stored = await ctx.storage.putBuffer(`speech.${extension}`, generated.bytes, {
|
|
1647
|
+
contentType: generated.contentType,
|
|
1648
|
+
kind: "audio",
|
|
1649
|
+
metadata: {
|
|
1650
|
+
primitive: "tts",
|
|
1651
|
+
provider,
|
|
1652
|
+
model,
|
|
1653
|
+
voice: payload.voice ?? null,
|
|
1654
|
+
styleInstructions: payload.instructions ?? null
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
ctx.logger.progress(1, "Speech generation complete");
|
|
1658
|
+
return {
|
|
1659
|
+
progress: 1,
|
|
1660
|
+
output: {
|
|
1661
|
+
files: compactUrls([stored.url]),
|
|
1662
|
+
primary_file_url: stored.url,
|
|
1663
|
+
audioUrl: stored.url,
|
|
1664
|
+
audio: {
|
|
1665
|
+
file_url: stored.url,
|
|
1666
|
+
content_type: generated.contentType,
|
|
1667
|
+
provider,
|
|
1668
|
+
model,
|
|
1669
|
+
voice: payload.voice ?? null,
|
|
1670
|
+
style_instructions: payload.instructions ?? null,
|
|
1671
|
+
text_length: payload.text.length
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
const sttPrimitive = definePrimitive({
|
|
1679
|
+
id: PRIMITIVE_STT_ID,
|
|
1680
|
+
kind: "stt",
|
|
1681
|
+
operations: {
|
|
1682
|
+
run: {
|
|
1683
|
+
description: "Transcribe speech from a video or audio URL (STT) into a simple subtitle transcript plus speaker-attributed multi-narration segments.",
|
|
1684
|
+
inputSchema: sttPayloadSchema,
|
|
1685
|
+
workflow: "run"
|
|
1686
|
+
}
|
|
1687
|
+
},
|
|
1688
|
+
jobs: {
|
|
1689
|
+
async run(ctx, input) {
|
|
1690
|
+
const payload = sttPayloadSchema.parse(input);
|
|
1691
|
+
const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
|
|
1692
|
+
let audioBytes;
|
|
1693
|
+
let audioContentType;
|
|
1694
|
+
let extractionMetadata = null;
|
|
1695
|
+
if (sourceKind === "audio") {
|
|
1696
|
+
ctx.logger.progress(0.1, "Fetching source audio");
|
|
1697
|
+
const response = await fetch(payload.source_url);
|
|
1698
|
+
if (!response.ok) {
|
|
1699
|
+
throw new Error(`Could not fetch source audio (${response.status}).`);
|
|
1700
|
+
}
|
|
1701
|
+
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1702
|
+
audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
|
|
1703
|
+
}
|
|
1704
|
+
else {
|
|
1705
|
+
// Videos (and unknown extensions) go through the ffmpeg seam: it
|
|
1706
|
+
// demuxes video AND normalizes odd audio containers into a small
|
|
1707
|
+
// mono mp3 the speech providers accept.
|
|
1708
|
+
ctx.logger.progress(0.1, "Extracting audio track from source", {
|
|
1709
|
+
executionMode: config.VIDFARM_PRIMITIVE_MEDIA_LAMBDA_URL ? "lambda" : "local_fallback"
|
|
1710
|
+
});
|
|
1711
|
+
const extracted = await executePrimitiveMediaOperation(ctx, {
|
|
1712
|
+
operation: "video_extract_audio",
|
|
1713
|
+
payload: {
|
|
1714
|
+
source_video_url: payload.source_url,
|
|
1715
|
+
output_format: "mp3",
|
|
1716
|
+
audio_bitrate_kbps: 64
|
|
1717
|
+
},
|
|
1718
|
+
outputKey: "source-audio.mp3"
|
|
1719
|
+
});
|
|
1720
|
+
if (!extracted.publicUrl) {
|
|
1721
|
+
throw new Error("Audio extraction produced no readable audio URL.");
|
|
1722
|
+
}
|
|
1723
|
+
const response = await fetch(extracted.publicUrl);
|
|
1724
|
+
if (!response.ok) {
|
|
1725
|
+
throw new Error(`Could not read extracted audio (${response.status}).`);
|
|
1726
|
+
}
|
|
1727
|
+
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1728
|
+
audioContentType = extracted.contentType || "audio/mpeg";
|
|
1729
|
+
extractionMetadata = extracted.metadata ?? null;
|
|
1730
|
+
}
|
|
1731
|
+
if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
|
|
1732
|
+
const sizeMb = Math.round(audioBytes.byteLength / (1024 * 1024));
|
|
1733
|
+
const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
|
|
1734
|
+
throw new Error(`Audio is too large to transcribe (${sizeMb}MB > ${maxMb}MB, roughly 40 minutes of speech). Trim the source with /videos/trim or /audio/trim and transcribe in parts.`);
|
|
1735
|
+
}
|
|
1736
|
+
ctx.logger.progress(0.45, "Transcribing speech");
|
|
1737
|
+
const provider = payload.provider ?? "gemini";
|
|
1738
|
+
const model = payload.model ?? defaultSpeechModelFor("stt", provider) ?? "";
|
|
1739
|
+
const transcription = await ctx.providers.transcribeSpeech({
|
|
1740
|
+
provider,
|
|
1741
|
+
model,
|
|
1742
|
+
audio: audioBytes,
|
|
1743
|
+
contentType: audioContentType,
|
|
1744
|
+
prompt: payload.prompt,
|
|
1745
|
+
language: payload.language,
|
|
1746
|
+
diarize: payload.diarize
|
|
1747
|
+
});
|
|
1748
|
+
ctx.logger.progress(0.85, "Storing transcript artifacts");
|
|
1749
|
+
const speakers = [...new Set(transcription.segments.map((segment) => segment.speaker))];
|
|
1750
|
+
const srt = buildSrtFromSegments(transcription.segments);
|
|
1751
|
+
const storedText = await ctx.storage.putText("transcript.txt", transcription.text || "");
|
|
1752
|
+
const storedSrt = srt ? await ctx.storage.putText("transcript.srt", srt, "application/x-subrip; charset=utf-8") : null;
|
|
1753
|
+
const storedJson = await ctx.storage.putJson("transcript.json", {
|
|
1754
|
+
source_url: payload.source_url,
|
|
1755
|
+
provider,
|
|
1756
|
+
model,
|
|
1757
|
+
language: transcription.language,
|
|
1758
|
+
diarization: transcription.diarization,
|
|
1759
|
+
text: transcription.text,
|
|
1760
|
+
speakers,
|
|
1761
|
+
segments: transcription.segments,
|
|
1762
|
+
...(extractionMetadata ? { extraction: extractionMetadata } : {})
|
|
1763
|
+
});
|
|
1764
|
+
const inlineOk = (transcription.text?.length ?? 0) <= MAX_INLINE_TRANSCRIPT_CHARS;
|
|
1765
|
+
ctx.logger.progress(1, "Speech transcription complete");
|
|
1766
|
+
return {
|
|
1767
|
+
progress: 1,
|
|
1768
|
+
output: {
|
|
1769
|
+
files: compactUrls([storedJson.url, storedSrt?.url, storedText.url]),
|
|
1770
|
+
primary_file_url: storedJson.url,
|
|
1771
|
+
sourceUrl: payload.source_url,
|
|
1772
|
+
text: inlineOk ? transcription.text : transcription.text.slice(0, MAX_INLINE_TRANSCRIPT_CHARS),
|
|
1773
|
+
text_truncated: !inlineOk,
|
|
1774
|
+
language: transcription.language,
|
|
1775
|
+
diarization: transcription.diarization,
|
|
1776
|
+
// Format 1 — SIMPLE SUBTITLE VERSION: plain transcript + SRT cues (no speakers).
|
|
1777
|
+
subtitles: {
|
|
1778
|
+
srt: inlineOk ? srt : null,
|
|
1779
|
+
srt_url: storedSrt?.url ?? null,
|
|
1780
|
+
text_url: storedText.url
|
|
1781
|
+
},
|
|
1782
|
+
// Format 2 — ADVANCED MULTI-SPEAKER VERSION: speaker-attributed timed segments.
|
|
1783
|
+
speakers,
|
|
1784
|
+
segments: inlineOk ? transcription.segments : undefined,
|
|
1785
|
+
transcript: {
|
|
1786
|
+
json_url: storedJson.url,
|
|
1787
|
+
content_type: "application/json"
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1562
1794
|
const videoProbePrimitive = definePrimitive({
|
|
1563
1795
|
id: PRIMITIVE_VIDEO_PROBE_ID,
|
|
1564
1796
|
kind: "video_probe",
|
|
@@ -2135,6 +2367,8 @@ class PrimitiveRegistry {
|
|
|
2135
2367
|
[videoConcatPrimitive.id, videoConcatPrimitive],
|
|
2136
2368
|
[audioConcatPrimitive.id, audioConcatPrimitive],
|
|
2137
2369
|
[audioNormalizePrimitive.id, audioNormalizePrimitive],
|
|
2370
|
+
[ttsPrimitive.id, ttsPrimitive],
|
|
2371
|
+
[sttPrimitive.id, sttPrimitive],
|
|
2138
2372
|
[hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
|
|
2139
2373
|
[brainstormHooksPrimitive.id, brainstormHooksPrimitive],
|
|
2140
2374
|
[brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
|
|
@@ -2259,6 +2493,11 @@ function defaultTextModelForProvider(provider) {
|
|
|
2259
2493
|
return "gpt-5.4-mini";
|
|
2260
2494
|
}
|
|
2261
2495
|
}
|
|
2496
|
+
// Extensions that are safe to feed a speech provider without the ffmpeg
|
|
2497
|
+
// normalization pass; everything else (video or unknown) gets demuxed first.
|
|
2498
|
+
function inferSpeechSourceKind(sourceUrl) {
|
|
2499
|
+
return /\.(mp3|m4a|aac|ogg|oga|flac|wav)(\?|#|$)/i.test(sourceUrl) ? "audio" : "video";
|
|
2500
|
+
}
|
|
2262
2501
|
function normalizeAiVideoDurationSeconds(explicitSeconds, milliseconds) {
|
|
2263
2502
|
const seconds = Number(explicitSeconds);
|
|
2264
2503
|
if (Number.isFinite(seconds) && seconds > 0) {
|
|
@@ -167,6 +167,9 @@ function resolveCostCenterSlug(input) {
|
|
|
167
167
|
|| explicitSlug === "primitive_media_lambda"
|
|
168
168
|
|| explicitSlug === "job_runner_lambda"
|
|
169
169
|
|| explicitSlug === "step_functions_standard"
|
|
170
|
+
// Clip hunting (long-form → short-form): AWS compute only — the AI
|
|
171
|
+
// tagging/embedding spend is BYOK and deliberately NOT wallet-billed.
|
|
172
|
+
|| explicitSlug === "clip_scan_lambda"
|
|
170
173
|
? explicitSlug
|
|
171
174
|
: null;
|
|
172
175
|
}
|
|
@@ -101,18 +101,50 @@ export async function probeVideo(videoPath) {
|
|
|
101
101
|
* Detect scene boundaries with the portable `select='gt(scene,T)',showinfo`
|
|
102
102
|
* pass, parsing pts_time from ffmpeg's stderr. Falls back to fixed-interval
|
|
103
103
|
* chunking when nothing is detected (e.g. a single continuous take), so a scan
|
|
104
|
-
* always yields clips. Same command shape on devcli and cloud.
|
|
104
|
+
* always yields clips. Same command shape on devcli and cloud. With
|
|
105
|
+
* opts.windows set, each window is detected independently and scene times are
|
|
106
|
+
* offset back into source time.
|
|
105
107
|
*/
|
|
106
108
|
export async function detectScenes(videoPath, opts = {}) {
|
|
109
|
+
const duration = opts.durationSec ?? (await probeVideo(videoPath)).duration_sec;
|
|
110
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
111
|
+
return [];
|
|
112
|
+
const windows = (opts.windows ?? [])
|
|
113
|
+
.map((w) => ({ start_sec: Math.max(0, w.start_sec), end_sec: Math.min(w.end_sec, duration) }))
|
|
114
|
+
.filter((w) => w.end_sec - w.start_sec > 0.25);
|
|
115
|
+
if (windows.length === 0) {
|
|
116
|
+
return detectScenesInSpan(videoPath, { ...opts, durationSec: duration, spanStart: 0, spanEnd: duration, indexOffset: 0 });
|
|
117
|
+
}
|
|
118
|
+
const scenes = [];
|
|
119
|
+
for (const window of windows) {
|
|
120
|
+
const detected = await detectScenesInSpan(videoPath, {
|
|
121
|
+
...opts,
|
|
122
|
+
durationSec: duration,
|
|
123
|
+
spanStart: window.start_sec,
|
|
124
|
+
spanEnd: window.end_sec,
|
|
125
|
+
indexOffset: scenes.length
|
|
126
|
+
});
|
|
127
|
+
scenes.push(...detected);
|
|
128
|
+
}
|
|
129
|
+
return scenes;
|
|
130
|
+
}
|
|
131
|
+
async function detectScenesInSpan(videoPath, opts) {
|
|
107
132
|
const threshold = opts.threshold ?? 0.3;
|
|
108
133
|
const minScene = opts.minSceneSec ?? 0.6;
|
|
109
134
|
const maxScene = opts.maxSceneSec ?? 8;
|
|
110
|
-
const
|
|
111
|
-
|
|
135
|
+
const { spanStart, spanEnd } = opts;
|
|
136
|
+
const spanDuration = spanEnd - spanStart;
|
|
137
|
+
if (!Number.isFinite(spanDuration) || spanDuration <= 0)
|
|
112
138
|
return [];
|
|
113
139
|
const ffmpeg = await resolveFfmpeg();
|
|
140
|
+
// -ss/-t BEFORE -i: input-seek so only the window is decoded. pts_time then
|
|
141
|
+
// restarts near 0, so cuts are offset back by spanStart below.
|
|
142
|
+
const seekArgs = spanStart > 0 || spanEnd < (opts.durationSec ?? Infinity)
|
|
143
|
+
? ["-ss", spanStart.toFixed(3), "-t", spanDuration.toFixed(3)]
|
|
144
|
+
: [];
|
|
114
145
|
const { stderr } = await run(ffmpeg, [
|
|
115
146
|
"-hide_banner",
|
|
147
|
+
...seekArgs,
|
|
116
148
|
"-i", videoPath,
|
|
117
149
|
"-filter:v", `select='gt(scene,${threshold})',showinfo`,
|
|
118
150
|
"-f", "null",
|
|
@@ -122,19 +154,19 @@ export async function detectScenes(videoPath, opts = {}) {
|
|
|
122
154
|
const re = /pts_time:([0-9.]+)/g;
|
|
123
155
|
let m;
|
|
124
156
|
while ((m = re.exec(stderr)) !== null) {
|
|
125
|
-
const t = Number(m[1]);
|
|
126
|
-
if (Number.isFinite(t) && t >
|
|
157
|
+
const t = Number(m[1]) + (seekArgs.length ? spanStart : 0);
|
|
158
|
+
if (Number.isFinite(t) && t > spanStart && t < spanEnd)
|
|
127
159
|
cuts.push(t);
|
|
128
160
|
}
|
|
129
161
|
cuts.sort((a, b) => a - b);
|
|
130
|
-
// Build [
|
|
131
|
-
let bounds = [
|
|
162
|
+
// Build [spanStart, cut1, cut2, …, spanEnd] boundaries.
|
|
163
|
+
let bounds = [spanStart, ...cuts, spanEnd];
|
|
132
164
|
// If nothing detected, chunk at maxScene.
|
|
133
165
|
if (cuts.length === 0) {
|
|
134
|
-
bounds = [
|
|
135
|
-
for (let t = maxScene; t <
|
|
166
|
+
bounds = [spanStart];
|
|
167
|
+
for (let t = spanStart + maxScene; t < spanEnd; t += maxScene)
|
|
136
168
|
bounds.push(t);
|
|
137
|
-
bounds.push(
|
|
169
|
+
bounds.push(spanEnd);
|
|
138
170
|
}
|
|
139
171
|
// Merge boundaries closer than minScene.
|
|
140
172
|
const merged = [bounds[0]];
|
|
@@ -155,11 +187,11 @@ export async function detectScenes(videoPath, opts = {}) {
|
|
|
155
187
|
for (let c = 0; c < chunks; c++) {
|
|
156
188
|
const s = start + c * step;
|
|
157
189
|
const e = c === chunks - 1 ? end : s + step;
|
|
158
|
-
scenes.push(makeScene(scenes.length, s, e));
|
|
190
|
+
scenes.push(makeScene(opts.indexOffset + scenes.length, s, e));
|
|
159
191
|
}
|
|
160
192
|
}
|
|
161
193
|
else {
|
|
162
|
-
scenes.push(makeScene(scenes.length, start, end));
|
|
194
|
+
scenes.push(makeScene(opts.indexOffset + scenes.length, start, end));
|
|
163
195
|
}
|
|
164
196
|
}
|
|
165
197
|
return scenes;
|
|
@@ -180,6 +212,7 @@ export async function extractKeyframes(input) {
|
|
|
180
212
|
const points = count === 1
|
|
181
213
|
? [start + dur / 2]
|
|
182
214
|
: Array.from({ length: count }, (_, i) => start + dur * ((i + 1) / (count + 1)));
|
|
215
|
+
const vf = input.cropFilter ? `${input.cropFilter},scale=${width}:-2` : `scale=${width}:-2`;
|
|
183
216
|
const outPaths = [];
|
|
184
217
|
for (let i = 0; i < points.length; i++) {
|
|
185
218
|
const out = path.join(input.outDir, `scene-${input.scene.index}-kf${i}.jpg`);
|
|
@@ -188,7 +221,7 @@ export async function extractKeyframes(input) {
|
|
|
188
221
|
"-ss", points[i].toFixed(3),
|
|
189
222
|
"-i", input.videoPath,
|
|
190
223
|
"-frames:v", "1",
|
|
191
|
-
"-vf",
|
|
224
|
+
"-vf", vf,
|
|
192
225
|
"-q:v", "5",
|
|
193
226
|
out
|
|
194
227
|
]);
|
|
@@ -202,32 +235,41 @@ export async function extractClip(input) {
|
|
|
202
235
|
mkdirSync(path.dirname(input.outPath), { recursive: true });
|
|
203
236
|
const ffmpeg = await resolveFfmpeg();
|
|
204
237
|
const { start_time_sec: start, duration_sec: dur } = input.scene;
|
|
205
|
-
|
|
238
|
+
// A crop cannot ride on stream-copy — force the re-encode path when present.
|
|
239
|
+
const args = input.reencode === false && !input.cropFilter
|
|
206
240
|
? ["-y", "-ss", start.toFixed(3), "-i", input.videoPath, "-t", dur.toFixed(3), "-c", "copy", "-avoid_negative_ts", "make_zero", input.outPath]
|
|
207
241
|
: [
|
|
208
242
|
"-y",
|
|
209
243
|
"-ss", start.toFixed(3),
|
|
210
244
|
"-i", input.videoPath,
|
|
211
245
|
"-t", dur.toFixed(3),
|
|
246
|
+
...(input.cropFilter ? ["-vf", input.cropFilter] : []),
|
|
212
247
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
|
|
213
248
|
"-c:a", "aac", "-b:a", "128k",
|
|
214
249
|
"-movflags", "+faststart",
|
|
215
250
|
input.outPath
|
|
216
251
|
];
|
|
217
|
-
const { code } = await run(ffmpeg, args);
|
|
218
|
-
|
|
252
|
+
const { code, stderr } = await run(ffmpeg, args);
|
|
253
|
+
const ok = code === 0 && existsSync(input.outPath);
|
|
254
|
+
if (!ok) {
|
|
255
|
+
input.onError?.(`ffmpeg exit ${code}: ${stderr.slice(-500).replace(/\s+/g, " ").trim()}`);
|
|
256
|
+
}
|
|
257
|
+
return ok;
|
|
219
258
|
}
|
|
220
259
|
/** Single representative thumbnail for the scene. */
|
|
221
260
|
export async function extractThumbnail(input) {
|
|
222
261
|
mkdirSync(path.dirname(input.outPath), { recursive: true });
|
|
223
262
|
const ffmpeg = await resolveFfmpeg();
|
|
224
263
|
const mid = input.scene.start_time_sec + input.scene.duration_sec / 2;
|
|
264
|
+
const vf = input.cropFilter
|
|
265
|
+
? `${input.cropFilter},scale=${input.width ?? 320}:-2`
|
|
266
|
+
: `scale=${input.width ?? 320}:-2`;
|
|
225
267
|
const { code } = await run(ffmpeg, [
|
|
226
268
|
"-y",
|
|
227
269
|
"-ss", mid.toFixed(3),
|
|
228
270
|
"-i", input.videoPath,
|
|
229
271
|
"-frames:v", "1",
|
|
230
|
-
"-vf",
|
|
272
|
+
"-vf", vf,
|
|
231
273
|
"-q:v", "3",
|
|
232
274
|
input.outPath
|
|
233
275
|
]);
|