@mevdragon/vidfarm-devcli 0.2.12 → 0.2.14
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/.env.example +37 -1
- package/README.md +27 -183
- package/SKILL.director.md +293 -0
- package/SKILL.platform.md +312 -0
- package/auto-create-hyperframe-templates/AUTO_CREATE_HYPERFRAME_TEMPLATES.md +275 -0
- package/auto-create-hyperframe-templates/extractor-system-prompt.md +128 -0
- package/auto-create-hyperframe-templates/input-manifest.schema.json +56 -0
- package/auto-create-hyperframe-templates/lambda-memory-estimator.md +118 -0
- package/auto-create-hyperframe-templates/production-graph.schema.json +314 -0
- package/auto-create-hyperframe-templates/runbook.md +198 -0
- package/auto-create-hyperframe-templates/scripts/create-hyperframe-template.mjs +368 -0
- package/auto-create-hyperframe-templates/template-plan.schema.json +311 -0
- package/auto-create-hyperframe-templates/template-planner-prompt.md +144 -0
- package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +116 -0
- package/auto-create-templates/extractor-system-prompt.md +6 -1
- package/auto-create-templates/template-plan.schema.json +73 -2
- package/auto-create-templates/template-planner-prompt.md +62 -1
- package/dist/src/account-pages-legacy.js +363 -89
- package/dist/src/account-pages-legacy.js.map +1 -0
- package/dist/src/account-pages.js +3 -2
- package/dist/src/account-pages.js.map +1 -0
- package/dist/src/app.js +7096 -1294
- package/dist/src/app.js.map +1 -0
- package/dist/src/cli.js +115 -29
- package/dist/src/cli.js.map +1 -0
- package/dist/src/composition-runtime.js +581 -0
- package/dist/src/composition-runtime.js.map +1 -0
- package/dist/src/config.js +69 -11
- package/dist/src/config.js.map +1 -0
- package/dist/src/context.js +280 -21
- package/dist/src/context.js.map +1 -0
- package/dist/src/dev-app-legacy.js +18 -17
- package/dist/src/dev-app-legacy.js.map +1 -0
- package/dist/src/dev-app.js +1 -0
- package/dist/src/dev-app.js.map +1 -0
- package/dist/src/dev-serve.js +169 -0
- package/dist/src/dev-serve.js.map +1 -0
- package/dist/src/domain.js +2 -1
- package/dist/src/domain.js.map +1 -0
- package/dist/src/editor-chat-history.js +10 -0
- package/dist/src/editor-chat-history.js.map +1 -0
- package/dist/src/editor-chat.js +135 -25
- package/dist/src/editor-chat.js.map +1 -0
- package/dist/src/editor-dark-theme.js +1128 -0
- package/dist/src/editor-dark-theme.js.map +1 -0
- package/dist/src/frontend/flockposter-cache-store.js +10 -2
- package/dist/src/frontend/flockposter-cache-store.js.map +1 -0
- package/dist/src/frontend/homepage-client.js +6 -3
- package/dist/src/frontend/homepage-client.js.map +1 -0
- package/dist/src/frontend/homepage-shared.js +1 -0
- package/dist/src/frontend/homepage-shared.js.map +1 -0
- package/dist/src/frontend/homepage-store.js +1 -0
- package/dist/src/frontend/homepage-store.js.map +1 -0
- package/dist/src/frontend/homepage-view.js +131 -18
- package/dist/src/frontend/homepage-view.js.map +1 -0
- package/dist/src/frontend/page-runtime-client.js +27 -3
- package/dist/src/frontend/page-runtime-client.js.map +1 -0
- package/dist/src/frontend/page-runtime-store.js +1 -0
- package/dist/src/frontend/page-runtime-store.js.map +1 -0
- package/dist/src/frontend/sentry.js +42 -0
- package/dist/src/frontend/sentry.js.map +1 -0
- package/dist/src/frontend/template-editor-chat.js +1711 -212
- package/dist/src/frontend/template-editor-chat.js.map +1 -0
- package/dist/src/help-page.js +333 -0
- package/dist/src/help-page.js.map +1 -0
- package/dist/src/homepage.js +169 -47
- package/dist/src/homepage.js.map +1 -0
- package/dist/src/hyperframes/composition.js +180 -0
- package/dist/src/hyperframes/composition.js.map +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/instrument.js +30 -0
- package/dist/src/instrument.js.map +1 -0
- package/dist/src/lib/crypto.js +1 -0
- package/dist/src/lib/crypto.js.map +1 -0
- package/dist/src/lib/dev-log.js +54 -0
- package/dist/src/lib/dev-log.js.map +1 -0
- package/dist/src/lib/display-name.js +11 -0
- package/dist/src/lib/display-name.js.map +1 -0
- package/dist/src/lib/ids.js +21 -1
- package/dist/src/lib/ids.js.map +1 -0
- package/dist/src/lib/images.js +1 -0
- package/dist/src/lib/images.js.map +1 -0
- package/dist/src/lib/json.js +1 -0
- package/dist/src/lib/json.js.map +1 -0
- package/dist/src/lib/template-dna.js +1 -0
- package/dist/src/lib/template-dna.js.map +1 -0
- package/dist/src/lib/template-paths.js +1 -0
- package/dist/src/lib/template-paths.js.map +1 -0
- package/dist/src/lib/template-style-options.js +29 -3
- package/dist/src/lib/template-style-options.js.map +1 -0
- package/dist/src/lib/time.js +1 -0
- package/dist/src/lib/time.js.map +1 -0
- package/dist/src/lib/video-quality-harness.js +60 -0
- package/dist/src/lib/video-quality-harness.js.map +1 -0
- package/dist/src/page-runtime.js +1 -0
- package/dist/src/page-runtime.js.map +1 -0
- package/dist/src/page-shell.js +403 -29
- package/dist/src/page-shell.js.map +1 -0
- package/dist/src/primitive-context.js +144 -8
- package/dist/src/primitive-context.js.map +1 -0
- package/dist/src/primitive-registry.js +1919 -78
- package/dist/src/primitive-registry.js.map +1 -0
- package/dist/src/primitive-sdk.js +1 -0
- package/dist/src/primitive-sdk.js.map +1 -0
- package/dist/src/primitives/remotion/html-image.js +3 -1
- package/dist/src/primitives/remotion/html-image.js.map +1 -0
- package/dist/src/primitives/remotion/media-slideshow.js +60 -0
- package/dist/src/primitives/remotion/media-slideshow.js.map +1 -0
- package/dist/src/react-page-shell.js +1 -0
- package/dist/src/react-page-shell.js.map +1 -0
- package/dist/src/ready-post-schedule-component.js +1 -0
- package/dist/src/ready-post-schedule-component.js.map +1 -0
- package/dist/src/registry.js +237 -24
- package/dist/src/registry.js.map +1 -0
- package/dist/src/runtime.js +3 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/services/api-call-history.js +4 -0
- package/dist/src/services/api-call-history.js.map +1 -0
- package/dist/src/services/auth.js +40 -50
- package/dist/src/services/auth.js.map +1 -0
- package/dist/src/services/billing.js +20 -44
- package/dist/src/services/billing.js.map +1 -0
- package/dist/src/services/chat-threads.js +10 -6
- package/dist/src/services/chat-threads.js.map +1 -0
- package/dist/src/services/fork-access.js +93 -0
- package/dist/src/services/fork-access.js.map +1 -0
- package/dist/src/services/fork-manifest.js +43 -0
- package/dist/src/services/fork-manifest.js.map +1 -0
- package/dist/src/services/ghostcut.js +132 -0
- package/dist/src/services/ghostcut.js.map +1 -0
- package/dist/src/services/hyperframes.js +1014 -0
- package/dist/src/services/hyperframes.js.map +1 -0
- package/dist/src/services/job-capacity.js +14 -0
- package/dist/src/services/job-capacity.js.map +1 -0
- package/dist/src/services/job-logs.js +4 -0
- package/dist/src/services/job-logs.js.map +1 -0
- package/dist/src/services/jobs.js +99 -91
- package/dist/src/services/jobs.js.map +1 -0
- package/dist/src/services/media-processing.js +743 -0
- package/dist/src/services/media-processing.js.map +1 -0
- package/dist/src/services/primitive-media-lambda.js +280 -0
- package/dist/src/services/primitive-media-lambda.js.map +1 -0
- package/dist/src/services/providers.js +1560 -178
- package/dist/src/services/providers.js.map +1 -0
- package/dist/src/services/rate-limits.js +3 -2
- package/dist/src/services/rate-limits.js.map +1 -0
- package/dist/src/services/remotion.js +495 -92
- package/dist/src/services/remotion.js.map +1 -0
- package/dist/src/services/serverless-auth.js +374 -0
- package/dist/src/services/serverless-auth.js.map +1 -0
- package/dist/src/services/serverless-jobs.js +1074 -0
- package/dist/src/services/serverless-jobs.js.map +1 -0
- package/dist/src/services/serverless-provider-keys.js +401 -0
- package/dist/src/services/serverless-provider-keys.js.map +1 -0
- package/dist/src/services/serverless-records.js +1088 -0
- package/dist/src/services/serverless-records.js.map +1 -0
- package/dist/src/services/serverless-template-configs.js +67 -0
- package/dist/src/services/serverless-template-configs.js.map +1 -0
- package/dist/src/services/storage.js +171 -35
- package/dist/src/services/storage.js.map +1 -0
- package/dist/src/services/template-certification.js +295 -3
- package/dist/src/services/template-certification.js.map +1 -0
- package/dist/src/services/template-loader.js +45 -1
- package/dist/src/services/template-loader.js.map +1 -0
- package/dist/src/services/template-runtime-bundles.js +217 -0
- package/dist/src/services/template-runtime-bundles.js.map +1 -0
- package/dist/src/services/template-sources.js +452 -87
- package/dist/src/services/template-sources.js.map +1 -0
- package/dist/src/services/video-normalization.js +2 -0
- package/dist/src/services/video-normalization.js.map +1 -0
- package/dist/src/services/webhooks.js +7 -6
- package/dist/src/services/webhooks.js.map +1 -0
- package/dist/src/template-editor-pages.js +2051 -1869
- package/dist/src/template-editor-pages.js.map +1 -0
- package/dist/src/template-editor-shell.js +1376 -181
- package/dist/src/template-editor-shell.js.map +1 -0
- package/dist/src/template-sdk.js +1 -0
- package/dist/src/template-sdk.js.map +1 -0
- package/dist/src/worker.js +11 -226
- package/dist/src/worker.js.map +1 -0
- package/package.json +47 -12
- package/public/assets/homepage-client-app.js +34 -8
- package/public/assets/page-runtime-client-app.js +44 -18
- package/readme.secret.md +89 -0
- package/templates/vidfarm_template_0000/README.md +106 -0
- package/templates/vidfarm_template_0000/SKILL.md +266 -0
- package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/composition.json +11 -0
- package/templates/vidfarm_template_0000/src/lib/images.js +221 -0
- package/templates/vidfarm_template_0000/src/remotion/Root.js +33 -0
- package/templates/vidfarm_template_0000/src/remotion/index.js +3 -0
- package/templates/vidfarm_template_0000/src/sdk.js +3 -0
- package/templates/vidfarm_template_0000/src/style-options.js +200 -0
- package/templates/vidfarm_template_0000/src/template-dna.js +9 -0
- package/templates/vidfarm_template_0000/src/template.js +1566 -0
- package/templates/vidfarm_template_0000/template.config.json +21 -0
- package/GETTING_STARTED.developers.md +0 -87
- package/SKILL.developer.md +0 -1186
- package/dist/src/db.js +0 -2007
|
@@ -0,0 +1,1566 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import * as fontkit from "fontkit";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import { normalizeToPortraitFrame } from "./lib/images.js";
|
|
8
|
+
import { defineTemplate, } from "./sdk.js";
|
|
9
|
+
import { TEMPLATE_FONT_IDS, TEMPLATE_FONT_OPTIONS, TEMPLATE_TEXT_BACKGROUND_COLOR_IDS, TEMPLATE_TEXT_BACKGROUND_COLOR_OPTIONS, } from "./style-options.js";
|
|
10
|
+
import { templateLinkToOriginal, templateVisualDna, templateViralDna } from "./template-dna.js";
|
|
11
|
+
const TEMPLATE_ID = "4c7a7e1a-7f35-4f30-9f86-9c8a63c7f2db";
|
|
12
|
+
const TEMPLATE_SLUG_ID = "template_0000";
|
|
13
|
+
const COMPOSITION_ID = "template-0000";
|
|
14
|
+
const TEMPLATE_PREVIEW_MEDIA = [
|
|
15
|
+
"templates/template-0000/about/preview-01.jpg",
|
|
16
|
+
];
|
|
17
|
+
const FRAME = { width: 1080, height: 1920 };
|
|
18
|
+
const TIKTOK_SAFE_AREA = {
|
|
19
|
+
left: 78,
|
|
20
|
+
right: 248,
|
|
21
|
+
top: 196,
|
|
22
|
+
bottom: 408,
|
|
23
|
+
};
|
|
24
|
+
const NATIVE_UI_TEXT_ZONE = {
|
|
25
|
+
minCenterXPercent: 0.38,
|
|
26
|
+
maxCenterXPercent: 0.62,
|
|
27
|
+
minCenterYPercent: 0.2,
|
|
28
|
+
maxCenterYPercent: 0.58,
|
|
29
|
+
};
|
|
30
|
+
const TEXT_BACKGROUND_NONE = "none";
|
|
31
|
+
const CUSTOM_CAPTION_FONT_ID = "custom";
|
|
32
|
+
const CUSTOM_TEXT_BACKGROUND_ID = "custom";
|
|
33
|
+
const TEXT_CHIP_PADDING_X = 20;
|
|
34
|
+
const TEXT_CHIP_PADDING_Y = 12;
|
|
35
|
+
const TEXT_CHIP_RADIUS = 12;
|
|
36
|
+
const TEXT_CHIP_OPACITY = 0.86;
|
|
37
|
+
const NATIVE_SHADOW_PADDING = 28;
|
|
38
|
+
const NATIVE_SHADOW_BLURS = [1.6, 4.8];
|
|
39
|
+
// Documentation-only dependency map for humans and agents.
|
|
40
|
+
// The current template standard has no first-class dependencies field, so
|
|
41
|
+
// provider/model requirements must be declared in source and SKILL.md.
|
|
42
|
+
const TEMPLATE_PROVIDER_REQUIREMENTS = {
|
|
43
|
+
image: [
|
|
44
|
+
{ provider: "openai", models: ["gpt-image-1", "gpt-image-2"], strict: false },
|
|
45
|
+
{ provider: "gemini", models: ["gemini-3.1-flash-image-preview", "gemini-2.5-flash-image"], strict: false },
|
|
46
|
+
{ provider: "openrouter", models: ["bytedance/seedance-2.0", "bytedance-seed/seedream-4.5"], strict: false, planned: true },
|
|
47
|
+
],
|
|
48
|
+
text: [
|
|
49
|
+
{ provider: "openai", models: ["gpt-5.4"], strict: false },
|
|
50
|
+
{ provider: "gemini", models: ["gemini-3.1-flash-lite", "gemini-2.5-flash-lite"], strict: false },
|
|
51
|
+
{ provider: "openrouter", models: ["qwen/qwen3.6-flash"], strict: false },
|
|
52
|
+
],
|
|
53
|
+
layout_analysis: [
|
|
54
|
+
{ provider: "openai", models: ["gpt-5.4"], strict: false },
|
|
55
|
+
{ provider: "gemini", models: ["gemini-3.1-flash-lite", "gemini-2.5-flash-lite"], strict: false },
|
|
56
|
+
{ provider: "openrouter", models: ["qwen/qwen3.6-flash"], strict: false },
|
|
57
|
+
],
|
|
58
|
+
video: [
|
|
59
|
+
{ provider: "openai", models: ["sora-2"], strict: false, planned: true },
|
|
60
|
+
{ provider: "gemini", models: ["veo-3.0-generate-001"], strict: false, planned: true },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
void TEMPLATE_PROVIDER_REQUIREMENTS;
|
|
64
|
+
const legacySlideInputSchema = z.union([
|
|
65
|
+
z.tuple([z.string().min(3), z.string().min(1)]),
|
|
66
|
+
z.tuple([
|
|
67
|
+
z.string().min(3),
|
|
68
|
+
z.string().min(1),
|
|
69
|
+
z.number().int().min(500).max(30000),
|
|
70
|
+
]),
|
|
71
|
+
]);
|
|
72
|
+
const structuredSlideInputSchema = z.object({
|
|
73
|
+
image_prompt: z.string().min(3),
|
|
74
|
+
image_prompt_attachments: z.array(z.string().url()).max(12).default([]),
|
|
75
|
+
caption: z.string().min(1),
|
|
76
|
+
duration_ms: z.number().int().min(500).max(30000).default(4000),
|
|
77
|
+
});
|
|
78
|
+
const slideInputSchema = z.union([
|
|
79
|
+
legacySlideInputSchema,
|
|
80
|
+
structuredSlideInputSchema,
|
|
81
|
+
]);
|
|
82
|
+
const createSlideshowInputSchema = z.object({
|
|
83
|
+
slides: z.array(slideInputSchema).min(1).max(20),
|
|
84
|
+
meta_details_prompt: z.string().min(8).max(4000).optional(),
|
|
85
|
+
});
|
|
86
|
+
const renderSlideSchema = z.object({
|
|
87
|
+
imageUrl: z.string().url(),
|
|
88
|
+
durationMs: z.number().int().min(500).max(30000),
|
|
89
|
+
});
|
|
90
|
+
const renderVideoInputSchema = z.object({
|
|
91
|
+
slides: z.array(renderSlideSchema).min(1).max(20),
|
|
92
|
+
});
|
|
93
|
+
const supportedImageProviders = ["openai", "gemini", "openrouter"];
|
|
94
|
+
const remotionEntryPoint = resolveRemotionEntryPoint();
|
|
95
|
+
const templateRuntimeConfig = resolveTemplateRuntimeConfig();
|
|
96
|
+
const remotionRuntimeConfig = {
|
|
97
|
+
serveUrl: templateRuntimeConfig.remotion?.serve_url ?? undefined,
|
|
98
|
+
};
|
|
99
|
+
const skillPath = resolveSkillPath();
|
|
100
|
+
const overlayFonts = resolveOverlayFonts();
|
|
101
|
+
const parsedFontCache = new Map();
|
|
102
|
+
export const template0000Definition = defineTemplate({
|
|
103
|
+
id: TEMPLATE_ID,
|
|
104
|
+
slugId: TEMPLATE_SLUG_ID,
|
|
105
|
+
version: "1.0.0",
|
|
106
|
+
about: {
|
|
107
|
+
title: "Template 0000",
|
|
108
|
+
description: "Starter TikTok-safe slideshow template with staged image generation, text compositing, and optional Remotion video rendering.",
|
|
109
|
+
difficulty: "easy",
|
|
110
|
+
viral_dna: templateViralDna,
|
|
111
|
+
visual_dna: templateVisualDna,
|
|
112
|
+
preview_media: [...TEMPLATE_PREVIEW_MEDIA],
|
|
113
|
+
link_to_original: templateLinkToOriginal,
|
|
114
|
+
links_to_inspiration: templateLinkToOriginal ? [templateLinkToOriginal] : [],
|
|
115
|
+
sound: {
|
|
116
|
+
sound_title: "original sound",
|
|
117
|
+
sound_url: ""
|
|
118
|
+
},
|
|
119
|
+
proposal_generator: "Given the director's product, customer, and angle, propose multiple slideshow concepts that fit this template's creator-native TikTok format. Return concrete post ideas that can either be used directly as single prompts or expanded into 10+ related prompts. Favor specific hooks, clear educational angles, founder stories, product proof, objections, comparisons, and repeatable series ideas.",
|
|
120
|
+
skeleton_prompt: "Create a [number]-slide TikTok slideshow for [audience] about [topic/product/angle]. Slide 1 hook: [hook]. Middle slides: [proof, explanation, story beat, comparison, or objection handling]. Final slide CTA or takeaway: [ending]. For each slide include exact overlay text plus a matching visual prompt. Tone: [creator-native / direct / educational / punchy].",
|
|
121
|
+
sample_prompts: [
|
|
122
|
+
"Make a 3-slide TikTok slideshow explaining why cold plunges feel so hard at first but get easier after a week. Use exact overlay text for each slide and generate matching cinematic background prompts.",
|
|
123
|
+
"Create a founder-story slideshow about launching a niche coffee brand, with one hook slide, one proof slide, and one CTA slide. Keep the tone creator-native and direct.",
|
|
124
|
+
"Build a product explainer slideshow for a posture-correcting desk setup. I want concise overlays, clean visual prompts, and output ready for optional Remotion rendering."
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
examples: [],
|
|
128
|
+
skillPath,
|
|
129
|
+
configSchema: z.object({
|
|
130
|
+
defaultProvider: z.enum(supportedImageProviders).default("gemini"),
|
|
131
|
+
textModel: z.string().optional(),
|
|
132
|
+
imageModel: z.string().optional(),
|
|
133
|
+
captionFont: z.enum([...TEMPLATE_FONT_IDS, CUSTOM_CAPTION_FONT_ID]).default("tiktok_sans_semibold"),
|
|
134
|
+
captionBackgroundColor: z
|
|
135
|
+
.enum([TEXT_BACKGROUND_NONE, ...TEMPLATE_TEXT_BACKGROUND_COLOR_IDS])
|
|
136
|
+
.default(TEXT_BACKGROUND_NONE),
|
|
137
|
+
customCaptionFontFamily: z.string().min(1).max(128).optional(),
|
|
138
|
+
customCaptionFontWeight: z.number().int().min(100).max(900).optional(),
|
|
139
|
+
customCaptionFontAssetPath: z.string().min(1).max(512).optional(),
|
|
140
|
+
customCaptionTextColorHex: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
141
|
+
customCaptionBackgroundColorHex: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
142
|
+
renderCompositionId: z.string().default(COMPOSITION_ID),
|
|
143
|
+
}),
|
|
144
|
+
operations: {
|
|
145
|
+
create_slideshow: {
|
|
146
|
+
description: "Generate TikTok-safe 9:16 background images, composite the exact overlay text into finished slide frames, and save a manifest.",
|
|
147
|
+
inputSchema: createSlideshowInputSchema,
|
|
148
|
+
workflow: "createSlideshowWorkflow",
|
|
149
|
+
webhookSupport: true,
|
|
150
|
+
smokeTestPayload: {
|
|
151
|
+
slides: [
|
|
152
|
+
["a founder at a desk", "Launch faster", 2400],
|
|
153
|
+
{
|
|
154
|
+
image_prompt: "close-up product photo",
|
|
155
|
+
image_prompt_attachments: [
|
|
156
|
+
"https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=1080&q=80",
|
|
157
|
+
],
|
|
158
|
+
caption: "Built in-house",
|
|
159
|
+
duration_ms: 3200,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
meta_details_prompt: "Target US TikTok skincare and startup audiences. Make the hook curiosity-driven, natural, and UGC-native.",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
render_video: {
|
|
166
|
+
description: "Turn finished slideshow frames into an auto-advancing vertical video using Remotion.",
|
|
167
|
+
inputSchema: renderVideoInputSchema,
|
|
168
|
+
workflow: "renderVideoWorkflow",
|
|
169
|
+
webhookSupport: true,
|
|
170
|
+
smokeTestPayload: {
|
|
171
|
+
slides: [
|
|
172
|
+
{
|
|
173
|
+
imageUrl: "https://images.unsplash.com/photo-1519389950473-47ba0277781c?auto=format&fit=crop&w=1080&q=80",
|
|
174
|
+
durationMs: 2400,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
imageUrl: "https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=1080&q=80",
|
|
178
|
+
durationMs: 3200,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
jobs: {
|
|
185
|
+
async createSlideshowWorkflow(ctx, input) {
|
|
186
|
+
const payload = createSlideshowInputSchema.parse(input);
|
|
187
|
+
ctx.logger.progress(0.04, "Starting template_0000 slideshow image stage");
|
|
188
|
+
const provider = parseImageProvider(ctx.templateConfig.defaultProvider);
|
|
189
|
+
const configuredTextModel = typeof ctx.templateConfig.textModel === "string"
|
|
190
|
+
? ctx.templateConfig.textModel
|
|
191
|
+
: null;
|
|
192
|
+
const configuredImageModel = typeof ctx.templateConfig.imageModel === "string"
|
|
193
|
+
? ctx.templateConfig.imageModel
|
|
194
|
+
: null;
|
|
195
|
+
const textModel = String(configuredTextModel ?? defaultTextModelForProvider(provider));
|
|
196
|
+
const imageModel = String(configuredImageModel ?? defaultImageModelForProvider(provider));
|
|
197
|
+
const textStyle = resolveTextStyleSpec(ctx.templateConfig);
|
|
198
|
+
const slides = [];
|
|
199
|
+
for (const [index, rawSlide] of payload.slides.entries()) {
|
|
200
|
+
const { sourceType, sourceValue, imagePrompt, imagePromptAttachments, overlayText, durationMs, } = normalizeSlideInput(rawSlide);
|
|
201
|
+
ctx.logger.progress(0.08 + (index / payload.slides.length) * 0.42, sourceType === "image_source"
|
|
202
|
+
? `Loading slide ${index + 1} source image`
|
|
203
|
+
: `Generating slide ${index + 1} image`);
|
|
204
|
+
const image = sourceType === "image_source"
|
|
205
|
+
? await loadExistingSlideImage(sourceValue)
|
|
206
|
+
: await generatePortraitCandidateImage(ctx, {
|
|
207
|
+
provider,
|
|
208
|
+
imageModel,
|
|
209
|
+
configuredProvider: provider,
|
|
210
|
+
configuredImageModel,
|
|
211
|
+
imagePrompt,
|
|
212
|
+
imagePromptAttachments,
|
|
213
|
+
overlayText,
|
|
214
|
+
});
|
|
215
|
+
if (sourceType === "ai_prompt") {
|
|
216
|
+
await ctx.billing.record({
|
|
217
|
+
type: "ai_generation",
|
|
218
|
+
costUsd: 0.04,
|
|
219
|
+
metadata: {
|
|
220
|
+
stage: "image_generation",
|
|
221
|
+
slideIndex: index,
|
|
222
|
+
model: imageModel,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
ctx.logger.progress(0.13 + (index / payload.slides.length) * 0.3, `Normalizing slide ${index + 1} to strict 9:16 portrait`);
|
|
227
|
+
const normalizedImage = await normalizeToPortraitFrame(image.bytes, FRAME);
|
|
228
|
+
const backgroundArtifact = await ctx.storage.putBuffer(`backgrounds/slide-${pad2(index + 1)}.png`, normalizedImage.bytes, {
|
|
229
|
+
contentType: normalizedImage.contentType,
|
|
230
|
+
kind: "image",
|
|
231
|
+
metadata: {
|
|
232
|
+
slideIndex: index,
|
|
233
|
+
prompt: image.prompt,
|
|
234
|
+
revisedPrompt: image.revisedPrompt,
|
|
235
|
+
width: normalizedImage.width,
|
|
236
|
+
height: normalizedImage.height,
|
|
237
|
+
aspectRatio: "9:16",
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
ctx.logger.progress(0.18 + (index / payload.slides.length) * 0.28, `Scoring TikTok-safe text layout for slide ${index + 1}`);
|
|
241
|
+
const layout = await chooseSlideLayout(ctx, {
|
|
242
|
+
provider,
|
|
243
|
+
model: textModel,
|
|
244
|
+
imageBytes: normalizedImage.bytes,
|
|
245
|
+
imageUrl: backgroundArtifact.url,
|
|
246
|
+
overlayText,
|
|
247
|
+
});
|
|
248
|
+
ctx.logger.progress(0.24 + (index / payload.slides.length) * 0.28, `Compositing caption into slide ${index + 1}`);
|
|
249
|
+
const frameBytes = await renderFinishedSlide(normalizedImage.bytes, overlayText, layout, textStyle);
|
|
250
|
+
const frameArtifact = await ctx.storage.putBuffer(`slides/slide-${pad2(index + 1)}.png`, frameBytes, {
|
|
251
|
+
contentType: "image/png",
|
|
252
|
+
kind: "image",
|
|
253
|
+
metadata: {
|
|
254
|
+
slideIndex: index,
|
|
255
|
+
overlayText,
|
|
256
|
+
layout,
|
|
257
|
+
aspectRatio: "9:16",
|
|
258
|
+
kind: "finished_slide_frame",
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
slides.push({
|
|
262
|
+
index,
|
|
263
|
+
sourceType,
|
|
264
|
+
sourceValue,
|
|
265
|
+
imagePrompt,
|
|
266
|
+
imagePromptAttachments,
|
|
267
|
+
overlayText,
|
|
268
|
+
durationMs,
|
|
269
|
+
backgroundImageUrl: backgroundArtifact.url,
|
|
270
|
+
frameImageUrl: frameArtifact.url,
|
|
271
|
+
prompt: image.prompt,
|
|
272
|
+
revisedPrompt: image.revisedPrompt,
|
|
273
|
+
layout,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
ctx.logger.progress(0.72, "Generating post metadata");
|
|
277
|
+
const metaDetails = await generateMetaDetails(ctx, {
|
|
278
|
+
provider,
|
|
279
|
+
textModel,
|
|
280
|
+
configuredProvider: provider,
|
|
281
|
+
configuredTextModel,
|
|
282
|
+
slides,
|
|
283
|
+
metaDetailsPrompt: payload.meta_details_prompt,
|
|
284
|
+
});
|
|
285
|
+
await ctx.billing.record({
|
|
286
|
+
type: "ai_generation",
|
|
287
|
+
costUsd: 0.01,
|
|
288
|
+
metadata: { stage: "meta_details_generation", model: textModel },
|
|
289
|
+
});
|
|
290
|
+
ctx.logger.progress(0.78, "Saving slideshow manifest");
|
|
291
|
+
const manifest = {
|
|
292
|
+
templateId: TEMPLATE_ID,
|
|
293
|
+
size: { width: FRAME.width, height: FRAME.height, aspectRatio: "9:16" },
|
|
294
|
+
textStyle: {
|
|
295
|
+
fontId: textStyle.fontId,
|
|
296
|
+
fontFamily: textStyle.fontFamily,
|
|
297
|
+
fontWeight: textStyle.fontWeight,
|
|
298
|
+
fill: textStyle.fill,
|
|
299
|
+
backgroundColorId: textStyle.backgroundColorId,
|
|
300
|
+
backgroundColorHex: textStyle.backgroundColorHex,
|
|
301
|
+
shadow: "soft native TikTok-style black drop shadow",
|
|
302
|
+
availableFonts: TEMPLATE_FONT_OPTIONS,
|
|
303
|
+
availableBackgroundColors: TEMPLATE_TEXT_BACKGROUND_COLOR_OPTIONS,
|
|
304
|
+
},
|
|
305
|
+
safeArea: TIKTOK_SAFE_AREA,
|
|
306
|
+
metaDetails,
|
|
307
|
+
slides,
|
|
308
|
+
};
|
|
309
|
+
const manifestArtifact = await ctx.storage.putJson(`manifests/${TEMPLATE_ID}.json`, manifest);
|
|
310
|
+
const files = slides
|
|
311
|
+
.flatMap((slide) => [slide.backgroundImageUrl, slide.frameImageUrl])
|
|
312
|
+
.filter((value) => Boolean(value));
|
|
313
|
+
if (manifestArtifact.url) {
|
|
314
|
+
files.push(manifestArtifact.url);
|
|
315
|
+
}
|
|
316
|
+
ctx.logger.progress(1, "Slideshow images complete", {
|
|
317
|
+
fileCount: files.length,
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
progress: 1,
|
|
321
|
+
output: {
|
|
322
|
+
files,
|
|
323
|
+
manifest: manifestArtifact,
|
|
324
|
+
...metaDetails,
|
|
325
|
+
metaDetails,
|
|
326
|
+
metaDetailsInput: {
|
|
327
|
+
meta_details_prompt: payload.meta_details_prompt ?? null,
|
|
328
|
+
slides: slides.map((slide) => ({
|
|
329
|
+
index: slide.index,
|
|
330
|
+
sourceType: slide.sourceType,
|
|
331
|
+
sourceValue: slide.sourceValue,
|
|
332
|
+
imagePrompt: slide.imagePrompt,
|
|
333
|
+
imagePromptAttachments: slide.imagePromptAttachments,
|
|
334
|
+
overlayText: slide.overlayText,
|
|
335
|
+
durationMs: slide.durationMs,
|
|
336
|
+
})),
|
|
337
|
+
},
|
|
338
|
+
renderVideoInput: {
|
|
339
|
+
slides: slides
|
|
340
|
+
.map((slide) => slide.frameImageUrl
|
|
341
|
+
? {
|
|
342
|
+
imageUrl: slide.frameImageUrl,
|
|
343
|
+
durationMs: slide.durationMs,
|
|
344
|
+
}
|
|
345
|
+
: null)
|
|
346
|
+
.filter((slide) => Boolean(slide)),
|
|
347
|
+
},
|
|
348
|
+
slides,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
async renderVideoWorkflow(ctx, input) {
|
|
353
|
+
const payload = renderVideoInputSchema.parse(input);
|
|
354
|
+
const compositionId = String(ctx.templateConfig.renderCompositionId ?? COMPOSITION_ID);
|
|
355
|
+
ctx.logger.progress(0.08, "Preparing slideshow frames for video render");
|
|
356
|
+
const slides = await resolveSlidesForRender(payload);
|
|
357
|
+
ctx.logger.progress(0.56, "Submitting Remotion render");
|
|
358
|
+
const render = await ctx.remotion.render({
|
|
359
|
+
compositionId,
|
|
360
|
+
serveUrl: remotionRuntimeConfig.serveUrl,
|
|
361
|
+
entryPoint: remotionEntryPoint,
|
|
362
|
+
outputKey: "renders/final.mp4",
|
|
363
|
+
fps: 30,
|
|
364
|
+
inputProps: {
|
|
365
|
+
slides,
|
|
366
|
+
fps: 30,
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
const files = slides.map((slide) => slide.imageUrl);
|
|
370
|
+
if (render.outputUrl) {
|
|
371
|
+
files.push(render.outputUrl);
|
|
372
|
+
}
|
|
373
|
+
ctx.logger.progress(1, "Slideshow video complete", {
|
|
374
|
+
fileCount: files.length,
|
|
375
|
+
renderId: render.renderId,
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
progress: 1,
|
|
379
|
+
output: {
|
|
380
|
+
files,
|
|
381
|
+
render,
|
|
382
|
+
slides,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
const IMAGE_PROMPT_SYSTEM_WRAPPER = [
|
|
389
|
+
"Create an exact 9:16 portrait slideshow image for a TikTok-style vertical video.",
|
|
390
|
+
"The composition must be designed for strict full-frame mobile portrait output at 1080x1920.",
|
|
391
|
+
"Hold the camera vertically. The generated image itself must be tall portrait, not a landscape image placed inside a vertical canvas.",
|
|
392
|
+
"Return only the background scene or subject image. This is not a phone screenshot, social app mockup, or UI composition.",
|
|
393
|
+
"Do not produce square, landscape, or loose portrait framing.",
|
|
394
|
+
"Do not letterbox, pillarbox, add borders, or leave empty margins.",
|
|
395
|
+
"Crop tighter if needed so the frame reads as a native vertical shot. Avoid fake poster framing, inset postcards, or giant blank padding.",
|
|
396
|
+
"Do not sacrifice the vertical composition just to keep the entire subject visible.",
|
|
397
|
+
"Leave less important negative space near the top edge, right edge, and lower portion of the frame so later video overlays can sit there without covering the subject.",
|
|
398
|
+
"Keep the right edge visually quieter and avoid placing important details there.",
|
|
399
|
+
"Keep the top edge visually quieter and avoid placing important details there.",
|
|
400
|
+
"Keep the lower portion visually quieter and avoid placing important details there.",
|
|
401
|
+
"Reserve one clean editorial text lane in the upper-left or center-left portion of the frame for the overlay.",
|
|
402
|
+
"That reserved lane is empty space only. Do not render the overlay or any placeholder text.",
|
|
403
|
+
"Do not render any phone UI, social media UI, app chrome, search bars, tabs, buttons, likes, comments, avatars, battery icons, status bars, frames, stickers, or interface elements.",
|
|
404
|
+
"Do not render any words, letters, captions, subtitles, titles, logos, signage, labels, watermarks, or typography inside the image.",
|
|
405
|
+
"The final image must contain zero visible text unless part of the scene itself.",
|
|
406
|
+
"Use cinematic lighting, crisp detail, and framing that already fits an exact 9:16 portrait frame, unless otherwise specified.",
|
|
407
|
+
"Avoid giant blank ceiling, wall, floor, or sky bands that make the frame feel padded or zoomed out.",
|
|
408
|
+
].join("\n");
|
|
409
|
+
function buildImagePrompt(imagePrompt, overlayText, imagePromptAttachments = []) {
|
|
410
|
+
return [
|
|
411
|
+
IMAGE_PROMPT_SYSTEM_WRAPPER,
|
|
412
|
+
`User visual prompt: ${imagePrompt}`,
|
|
413
|
+
...(imagePromptAttachments.length
|
|
414
|
+
? [
|
|
415
|
+
`Use the attached reference media as visual guidance for subject matter, style, composition, or product details, but still return one fresh final image.`,
|
|
416
|
+
]
|
|
417
|
+
: []),
|
|
418
|
+
`Reserve room for a short editorial overlay approximately ${overlayText.length} characters long, but do not render the overlay text itself.`,
|
|
419
|
+
].join("\n");
|
|
420
|
+
}
|
|
421
|
+
async function generatePortraitCandidateImage(ctx, input) {
|
|
422
|
+
const attempts = [
|
|
423
|
+
buildImagePrompt(input.imagePrompt, input.overlayText, input.imagePromptAttachments),
|
|
424
|
+
[
|
|
425
|
+
buildImagePrompt(input.imagePrompt, input.overlayText, input.imagePromptAttachments),
|
|
426
|
+
"Critical correction: the previous output risked being too wide.",
|
|
427
|
+
"Return a true full-frame mobile portrait composition.",
|
|
428
|
+
"Do not create a landscape scene floating inside a vertical canvas.",
|
|
429
|
+
"The subject and horizon should already be composed for 9:16 viewing.",
|
|
430
|
+
"Tighter framing is preferred over wide empty margins.",
|
|
431
|
+
].join("\n"),
|
|
432
|
+
[
|
|
433
|
+
buildImagePrompt(input.imagePrompt, input.overlayText, input.imagePromptAttachments),
|
|
434
|
+
"Critical correction: return an unmistakably vertical composition.",
|
|
435
|
+
"Push the camera closer if needed so the frame reads as native 9:16 portrait.",
|
|
436
|
+
"No inset landscape window, no postcard composition, no wide aerial framing.",
|
|
437
|
+
"Use a clean magazine-style composition with one obvious text lane.",
|
|
438
|
+
].join("\n"),
|
|
439
|
+
[
|
|
440
|
+
buildImagePrompt(input.imagePrompt, input.overlayText, input.imagePromptAttachments),
|
|
441
|
+
"Critical correction: the previous output likely had empty padding or weak mobile framing.",
|
|
442
|
+
"Fill the frame with a real handheld-phone portrait composition.",
|
|
443
|
+
"No white bars, no blank top or bottom bands, and no subject floating in the middle of excess space.",
|
|
444
|
+
"Crop decisively and favor a native social-video camera distance.",
|
|
445
|
+
].join("\n"),
|
|
446
|
+
];
|
|
447
|
+
let best = null;
|
|
448
|
+
let lastError = null;
|
|
449
|
+
for (const provider of providerFailoverOrder(input.provider)) {
|
|
450
|
+
const imageModel = resolveImageModelForAttempt(provider, input.configuredProvider, input.configuredImageModel);
|
|
451
|
+
try {
|
|
452
|
+
for (const prompt of attempts) {
|
|
453
|
+
const image = await ctx.providers.generateImage({
|
|
454
|
+
provider,
|
|
455
|
+
model: imageModel,
|
|
456
|
+
prompt,
|
|
457
|
+
promptAttachments: input.imagePromptAttachments,
|
|
458
|
+
size: sourceImageSizeForProvider(provider),
|
|
459
|
+
aspectRatio: "9:16",
|
|
460
|
+
imageSize: sourceImageOutputSizeForProvider(provider, imageModel),
|
|
461
|
+
});
|
|
462
|
+
const score = await portraitScore(image.bytes);
|
|
463
|
+
if (!best || score < best.score) {
|
|
464
|
+
best = { ...image, prompt, score };
|
|
465
|
+
}
|
|
466
|
+
if (score <= 0.16) {
|
|
467
|
+
return best;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (best) {
|
|
471
|
+
return best;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
476
|
+
ctx.logger.warn("Image generation provider failed, trying next provider", {
|
|
477
|
+
provider,
|
|
478
|
+
model: imageModel,
|
|
479
|
+
message: lastError.message,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!best) {
|
|
484
|
+
throw lastError ?? new Error("No image candidate was generated.");
|
|
485
|
+
}
|
|
486
|
+
return best;
|
|
487
|
+
}
|
|
488
|
+
function defaultLayout() {
|
|
489
|
+
return {
|
|
490
|
+
zone: "center",
|
|
491
|
+
align: "center",
|
|
492
|
+
maxWidthPercent: 58,
|
|
493
|
+
anchorXPercent: 0.5,
|
|
494
|
+
anchorYPercent: 0.28,
|
|
495
|
+
justification: "Default placement uses a centered TikTok-native text zone in the middle third of the safe frame.",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function parseImageProvider(value) {
|
|
499
|
+
if (typeof value === "string" && isImageProvider(value)) {
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
return "gemini";
|
|
503
|
+
}
|
|
504
|
+
function isImageProvider(value) {
|
|
505
|
+
return (typeof value === "string" &&
|
|
506
|
+
supportedImageProviders.includes(value));
|
|
507
|
+
}
|
|
508
|
+
function sourceImageSizeForProvider(provider) {
|
|
509
|
+
if (provider === "openai") {
|
|
510
|
+
return "1024x1536";
|
|
511
|
+
}
|
|
512
|
+
return "1080x1920";
|
|
513
|
+
}
|
|
514
|
+
function defaultImageModelForProvider(provider) {
|
|
515
|
+
if (provider === "openai") {
|
|
516
|
+
return "gpt-image-1";
|
|
517
|
+
}
|
|
518
|
+
if (provider === "openrouter") {
|
|
519
|
+
return "bytedance-seed/seedream-4.5";
|
|
520
|
+
}
|
|
521
|
+
return "gemini-2.5-flash-image";
|
|
522
|
+
}
|
|
523
|
+
function defaultTextModelForProvider(provider) {
|
|
524
|
+
if (provider === "openai") {
|
|
525
|
+
return "gpt-4.1-mini";
|
|
526
|
+
}
|
|
527
|
+
if (provider === "openrouter") {
|
|
528
|
+
return "openai/gpt-4.1-mini";
|
|
529
|
+
}
|
|
530
|
+
return "gemini-3.1-flash-lite";
|
|
531
|
+
}
|
|
532
|
+
function sourceImageOutputSizeForProvider(provider, model) {
|
|
533
|
+
if (provider === "openrouter") {
|
|
534
|
+
return "2K";
|
|
535
|
+
}
|
|
536
|
+
if (provider !== "gemini") {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
if (model === "gemini-3-pro-image-preview") {
|
|
540
|
+
return "1K";
|
|
541
|
+
}
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
function providerFailoverOrder(primary) {
|
|
545
|
+
return [
|
|
546
|
+
primary,
|
|
547
|
+
...supportedImageProviders.filter((provider) => provider !== primary),
|
|
548
|
+
];
|
|
549
|
+
}
|
|
550
|
+
function resolveImageModelForAttempt(provider, configuredProvider, configuredImageModel) {
|
|
551
|
+
if (provider === configuredProvider && configuredImageModel) {
|
|
552
|
+
return configuredImageModel;
|
|
553
|
+
}
|
|
554
|
+
return defaultImageModelForProvider(provider);
|
|
555
|
+
}
|
|
556
|
+
function resolveTextModelForAttempt(provider, configuredProvider, configuredTextModel) {
|
|
557
|
+
if (provider === configuredProvider && configuredTextModel) {
|
|
558
|
+
return configuredTextModel;
|
|
559
|
+
}
|
|
560
|
+
return defaultTextModelForProvider(provider);
|
|
561
|
+
}
|
|
562
|
+
function resolveTextStyleSpec(config) {
|
|
563
|
+
const fontId = config.captionFont === CUSTOM_CAPTION_FONT_ID
|
|
564
|
+
? CUSTOM_CAPTION_FONT_ID
|
|
565
|
+
: isTemplateFontId(config.captionFont)
|
|
566
|
+
? config.captionFont
|
|
567
|
+
: "tiktok_sans_semibold";
|
|
568
|
+
const backgroundColorId = typeof config.customCaptionBackgroundColorHex === "string"
|
|
569
|
+
? CUSTOM_TEXT_BACKGROUND_ID
|
|
570
|
+
: config.captionBackgroundColor === TEXT_BACKGROUND_NONE ||
|
|
571
|
+
isTemplateBackgroundColorId(config.captionBackgroundColor)
|
|
572
|
+
? config.captionBackgroundColor
|
|
573
|
+
: TEXT_BACKGROUND_NONE;
|
|
574
|
+
const fontOption = fontId === CUSTOM_CAPTION_FONT_ID
|
|
575
|
+
? null
|
|
576
|
+
: (TEMPLATE_FONT_OPTIONS.find((option) => option.id === fontId) ??
|
|
577
|
+
TEMPLATE_FONT_OPTIONS[0]);
|
|
578
|
+
const backgroundOption = backgroundColorId &&
|
|
579
|
+
backgroundColorId !== TEXT_BACKGROUND_NONE &&
|
|
580
|
+
backgroundColorId !== CUSTOM_TEXT_BACKGROUND_ID
|
|
581
|
+
? (TEMPLATE_TEXT_BACKGROUND_COLOR_OPTIONS.find((option) => option.id === backgroundColorId) ?? null)
|
|
582
|
+
: null;
|
|
583
|
+
const customFontPath = fontId === CUSTOM_CAPTION_FONT_ID &&
|
|
584
|
+
typeof config.customCaptionFontAssetPath === "string"
|
|
585
|
+
? resolveCustomCaptionFontPath(config.customCaptionFontAssetPath)
|
|
586
|
+
: null;
|
|
587
|
+
const resolvedFontOption = fontOption ?? TEMPLATE_FONT_OPTIONS[0];
|
|
588
|
+
return {
|
|
589
|
+
fontId,
|
|
590
|
+
fontFamily: fontId === CUSTOM_CAPTION_FONT_ID &&
|
|
591
|
+
typeof config.customCaptionFontFamily === "string"
|
|
592
|
+
? config.customCaptionFontFamily
|
|
593
|
+
: resolvedFontOption.family,
|
|
594
|
+
fontWeight: fontId === CUSTOM_CAPTION_FONT_ID &&
|
|
595
|
+
typeof config.customCaptionFontWeight === "number"
|
|
596
|
+
? config.customCaptionFontWeight
|
|
597
|
+
: resolvedFontOption.weight,
|
|
598
|
+
fontPath: fontId === CUSTOM_CAPTION_FONT_ID
|
|
599
|
+
? customFontPath
|
|
600
|
+
: overlayFonts[resolvedFontOption.id].path,
|
|
601
|
+
fill: typeof config.customCaptionTextColorHex === "string"
|
|
602
|
+
? config.customCaptionTextColorHex
|
|
603
|
+
: "#ffffff",
|
|
604
|
+
backgroundColorId: backgroundColorId ?? TEXT_BACKGROUND_NONE,
|
|
605
|
+
backgroundColorHex: backgroundColorId === CUSTOM_TEXT_BACKGROUND_ID &&
|
|
606
|
+
typeof config.customCaptionBackgroundColorHex === "string"
|
|
607
|
+
? config.customCaptionBackgroundColorHex
|
|
608
|
+
: backgroundOption?.hex ?? null,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function isTemplateFontId(value) {
|
|
612
|
+
return (typeof value === "string" &&
|
|
613
|
+
TEMPLATE_FONT_IDS.includes(value));
|
|
614
|
+
}
|
|
615
|
+
function isTemplateBackgroundColorId(value) {
|
|
616
|
+
return (typeof value === "string" &&
|
|
617
|
+
TEMPLATE_TEXT_BACKGROUND_COLOR_IDS.includes(value));
|
|
618
|
+
}
|
|
619
|
+
function pad2(value) {
|
|
620
|
+
return String(value).padStart(2, "0");
|
|
621
|
+
}
|
|
622
|
+
async function resolveSlidesForRender(input) {
|
|
623
|
+
return input.slides;
|
|
624
|
+
}
|
|
625
|
+
const slideshowManifestSchema = z.object({
|
|
626
|
+
templateId: z.string(),
|
|
627
|
+
size: z.object({
|
|
628
|
+
width: z.number(),
|
|
629
|
+
height: z.number(),
|
|
630
|
+
aspectRatio: z.literal("9:16"),
|
|
631
|
+
}),
|
|
632
|
+
textStyle: z.object({
|
|
633
|
+
fontId: z.enum([...TEMPLATE_FONT_IDS, CUSTOM_CAPTION_FONT_ID]),
|
|
634
|
+
fontFamily: z.string(),
|
|
635
|
+
fontWeight: z.number(),
|
|
636
|
+
fill: z.string(),
|
|
637
|
+
backgroundColorId: z.enum([
|
|
638
|
+
TEXT_BACKGROUND_NONE,
|
|
639
|
+
CUSTOM_TEXT_BACKGROUND_ID,
|
|
640
|
+
...TEMPLATE_TEXT_BACKGROUND_COLOR_IDS,
|
|
641
|
+
]),
|
|
642
|
+
backgroundColorHex: z.string().nullable(),
|
|
643
|
+
shadow: z.string(),
|
|
644
|
+
availableFonts: z.array(z.object({
|
|
645
|
+
id: z.enum(TEMPLATE_FONT_IDS),
|
|
646
|
+
label: z.string(),
|
|
647
|
+
family: z.string(),
|
|
648
|
+
weight: z.number(),
|
|
649
|
+
assetFile: z.string().optional(),
|
|
650
|
+
fontsourceFile: z.string().optional(),
|
|
651
|
+
source: z.string(),
|
|
652
|
+
role: z.string(),
|
|
653
|
+
aliasOf: z.string().optional(),
|
|
654
|
+
})),
|
|
655
|
+
availableBackgroundColors: z.array(z.object({
|
|
656
|
+
id: z.enum(TEMPLATE_TEXT_BACKGROUND_COLOR_IDS),
|
|
657
|
+
label: z.string(),
|
|
658
|
+
hex: z.string(),
|
|
659
|
+
})),
|
|
660
|
+
}),
|
|
661
|
+
safeArea: z.object({
|
|
662
|
+
left: z.number(),
|
|
663
|
+
right: z.number(),
|
|
664
|
+
top: z.number(),
|
|
665
|
+
bottom: z.number(),
|
|
666
|
+
}),
|
|
667
|
+
metaDetails: z.object({
|
|
668
|
+
title: z.string(),
|
|
669
|
+
description: z.string(),
|
|
670
|
+
pinned_comment: z.string(),
|
|
671
|
+
location: z.string(),
|
|
672
|
+
song: z.string(),
|
|
673
|
+
}),
|
|
674
|
+
slides: z.array(z.object({
|
|
675
|
+
index: z.number(),
|
|
676
|
+
sourceType: z.enum(["ai_prompt", "image_source"]),
|
|
677
|
+
sourceValue: z.string(),
|
|
678
|
+
imagePrompt: z.string(),
|
|
679
|
+
imagePromptAttachments: z.array(z.string().url()),
|
|
680
|
+
overlayText: z.string(),
|
|
681
|
+
durationMs: z.number().int(),
|
|
682
|
+
backgroundImageUrl: z.string().url().nullable(),
|
|
683
|
+
frameImageUrl: z.string().url().nullable(),
|
|
684
|
+
prompt: z.string(),
|
|
685
|
+
revisedPrompt: z.string().nullable(),
|
|
686
|
+
layout: z.object({
|
|
687
|
+
zone: z.enum(["top", "center", "bottom"]),
|
|
688
|
+
align: z.enum(["left", "center", "right"]),
|
|
689
|
+
maxWidthPercent: z.number(),
|
|
690
|
+
anchorXPercent: z.number().optional(),
|
|
691
|
+
anchorYPercent: z.number().optional(),
|
|
692
|
+
justification: z.string(),
|
|
693
|
+
}),
|
|
694
|
+
})),
|
|
695
|
+
});
|
|
696
|
+
function normalizeSlideInput(input) {
|
|
697
|
+
if (Array.isArray(input)) {
|
|
698
|
+
return normalizeSlideFields({
|
|
699
|
+
primarySource: input[0],
|
|
700
|
+
imagePromptAttachments: [],
|
|
701
|
+
overlayText: input[1],
|
|
702
|
+
durationMs: input[2] ?? 4000,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
return normalizeSlideFields({
|
|
706
|
+
primarySource: input.image_prompt,
|
|
707
|
+
imagePromptAttachments: input.image_prompt_attachments,
|
|
708
|
+
overlayText: input.caption,
|
|
709
|
+
durationMs: input.duration_ms,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
function normalizeSlideFields(input) {
|
|
713
|
+
const sourceValue = input.primarySource.trim();
|
|
714
|
+
const existingImageSource = resolveExistingImageSource(sourceValue);
|
|
715
|
+
return {
|
|
716
|
+
sourceType: existingImageSource ? "image_source" : "ai_prompt",
|
|
717
|
+
sourceValue: existingImageSource ?? sourceValue,
|
|
718
|
+
imagePrompt: sourceValue,
|
|
719
|
+
imagePromptAttachments: input.imagePromptAttachments,
|
|
720
|
+
overlayText: input.overlayText,
|
|
721
|
+
durationMs: input.durationMs,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
async function loadExistingSlideImage(sourceValue) {
|
|
725
|
+
const resolved = resolveExistingImageSource(sourceValue);
|
|
726
|
+
if (!resolved) {
|
|
727
|
+
throw new Error(`Slide source is not a supported image URL or file path: ${sourceValue}`);
|
|
728
|
+
}
|
|
729
|
+
const bytes = isLocalFileSource(resolved)
|
|
730
|
+
? readFileSync(resolveLocalFileSourcePath(resolved))
|
|
731
|
+
: await fetchExternalImageBytes(resolved);
|
|
732
|
+
const contentType = await detectImageContentType(bytes);
|
|
733
|
+
return {
|
|
734
|
+
bytes,
|
|
735
|
+
contentType,
|
|
736
|
+
revisedPrompt: null,
|
|
737
|
+
prompt: sourceValue,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function resolveExistingImageSource(value) {
|
|
741
|
+
const trimmed = value.trim();
|
|
742
|
+
if (!trimmed) {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
const parsedUrl = parseUrl(trimmed);
|
|
746
|
+
if (parsedUrl &&
|
|
747
|
+
["http:", "https:", "file:", "data:"].includes(parsedUrl.protocol)) {
|
|
748
|
+
return trimmed;
|
|
749
|
+
}
|
|
750
|
+
const localPath = resolveLocalPathIfPresent(trimmed);
|
|
751
|
+
if (localPath) {
|
|
752
|
+
return localPath;
|
|
753
|
+
}
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
function parseUrl(value) {
|
|
757
|
+
try {
|
|
758
|
+
return new URL(value);
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function resolveLocalPathIfPresent(value) {
|
|
765
|
+
const expandedPath = value.startsWith("~/")
|
|
766
|
+
? path.join(process.env.HOME ?? "", value.slice(2))
|
|
767
|
+
: value;
|
|
768
|
+
const candidate = path.resolve(expandedPath);
|
|
769
|
+
return existsSync(candidate) ? candidate : null;
|
|
770
|
+
}
|
|
771
|
+
function isLocalFileSource(sourceValue) {
|
|
772
|
+
const parsedUrl = parseUrl(sourceValue);
|
|
773
|
+
return !parsedUrl || parsedUrl.protocol === "file:";
|
|
774
|
+
}
|
|
775
|
+
function resolveLocalFileSourcePath(sourceValue) {
|
|
776
|
+
const parsedUrl = parseUrl(sourceValue);
|
|
777
|
+
return parsedUrl?.protocol === "file:"
|
|
778
|
+
? fileURLToPath(parsedUrl)
|
|
779
|
+
: path.resolve(sourceValue);
|
|
780
|
+
}
|
|
781
|
+
async function fetchExternalImageBytes(sourceValue) {
|
|
782
|
+
const response = await fetch(sourceValue);
|
|
783
|
+
if (!response.ok) {
|
|
784
|
+
throw new Error(`Could not fetch slide source image: ${response.status} ${response.statusText}`);
|
|
785
|
+
}
|
|
786
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
787
|
+
}
|
|
788
|
+
async function detectImageContentType(bytes) {
|
|
789
|
+
try {
|
|
790
|
+
const metadata = await sharp(bytes).metadata();
|
|
791
|
+
if (metadata.format) {
|
|
792
|
+
return contentTypeForSharpFormat(metadata.format);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
throw new Error(`Slide source could not be decoded as an image: ${error instanceof Error ? error.message : String(error)}`);
|
|
797
|
+
}
|
|
798
|
+
return "image/png";
|
|
799
|
+
}
|
|
800
|
+
function contentTypeForSharpFormat(format) {
|
|
801
|
+
switch (format) {
|
|
802
|
+
case "jpeg":
|
|
803
|
+
return "image/jpeg";
|
|
804
|
+
case "png":
|
|
805
|
+
return "image/png";
|
|
806
|
+
case "webp":
|
|
807
|
+
return "image/webp";
|
|
808
|
+
case "gif":
|
|
809
|
+
return "image/gif";
|
|
810
|
+
case "avif":
|
|
811
|
+
return "image/avif";
|
|
812
|
+
case "tiff":
|
|
813
|
+
return "image/tiff";
|
|
814
|
+
case "svg":
|
|
815
|
+
return "image/svg+xml";
|
|
816
|
+
case "heif":
|
|
817
|
+
return "image/heif";
|
|
818
|
+
default:
|
|
819
|
+
return `image/${format}`;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
async function generateMetaDetails(ctx, input) {
|
|
823
|
+
const prompt = buildMetaDetailsPrompt(input.slides, input.metaDetailsPrompt);
|
|
824
|
+
let lastError = null;
|
|
825
|
+
for (const provider of providerFailoverOrder(input.provider)) {
|
|
826
|
+
const textModel = resolveTextModelForAttempt(provider, input.configuredProvider, input.configuredTextModel);
|
|
827
|
+
try {
|
|
828
|
+
const response = await ctx.providers.generateText({
|
|
829
|
+
provider,
|
|
830
|
+
model: textModel,
|
|
831
|
+
prompt,
|
|
832
|
+
temperature: 0.7,
|
|
833
|
+
});
|
|
834
|
+
return parseMetaDetailsResponse(response.text, input.slides);
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
838
|
+
ctx.logger.warn("Metadata provider failed, trying next provider", {
|
|
839
|
+
provider,
|
|
840
|
+
model: textModel,
|
|
841
|
+
message: lastError.message,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
throw lastError ?? new Error("No metadata provider succeeded.");
|
|
846
|
+
}
|
|
847
|
+
function buildMetaDetailsPrompt(slides, metaDetailsPrompt) {
|
|
848
|
+
return [
|
|
849
|
+
"You are writing TikTok post metadata for a vertical slideshow video.",
|
|
850
|
+
"Return only valid JSON with exactly these string keys: title, description, pinned_comment, location, song.",
|
|
851
|
+
"Do not include markdown fences or commentary.",
|
|
852
|
+
"Keep title under 90 characters.",
|
|
853
|
+
"Keep description under 300 characters.",
|
|
854
|
+
"Keep pinned_comment under 120 characters.",
|
|
855
|
+
"Keep location under 80 characters.",
|
|
856
|
+
"Keep song under 120 characters.",
|
|
857
|
+
"Write concise, high-performing, native-feeling TikTok social copy.",
|
|
858
|
+
"Use the slide captions and visual prompts to infer the post theme.",
|
|
859
|
+
metaDetailsPrompt
|
|
860
|
+
? `Additional user guidance:\n${metaDetailsPrompt}`
|
|
861
|
+
: "No extra user guidance was supplied.",
|
|
862
|
+
"Slides:",
|
|
863
|
+
...slides.map((slide) => [
|
|
864
|
+
`Slide ${slide.index + 1}:`,
|
|
865
|
+
`caption=${slide.overlayText}`,
|
|
866
|
+
`image_prompt=${slide.imagePrompt}`,
|
|
867
|
+
slide.imagePromptAttachments.length
|
|
868
|
+
? `attachments=${slide.imagePromptAttachments.join(", ")}`
|
|
869
|
+
: "attachments=none",
|
|
870
|
+
`duration_ms=${slide.durationMs}`,
|
|
871
|
+
].join("\n")),
|
|
872
|
+
].join("\n\n");
|
|
873
|
+
}
|
|
874
|
+
function parseMetaDetailsResponse(raw, slides) {
|
|
875
|
+
const fallback = buildFallbackMetaDetails(slides);
|
|
876
|
+
const cleaned = raw
|
|
877
|
+
.trim()
|
|
878
|
+
.replace(/^```json\s*/i, "")
|
|
879
|
+
.replace(/^```\s*/i, "")
|
|
880
|
+
.replace(/\s*```$/i, "");
|
|
881
|
+
try {
|
|
882
|
+
const parsed = z
|
|
883
|
+
.object({
|
|
884
|
+
title: z.string().min(1),
|
|
885
|
+
description: z.string().min(1),
|
|
886
|
+
pinned_comment: z.string().min(1),
|
|
887
|
+
location: z.string().min(1),
|
|
888
|
+
song: z.string().min(1),
|
|
889
|
+
})
|
|
890
|
+
.parse(JSON.parse(cleaned));
|
|
891
|
+
return parsed;
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return fallback;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function buildFallbackMetaDetails(slides) {
|
|
898
|
+
const leadCaption = slides[0]?.overlayText ?? "Watch this";
|
|
899
|
+
const supportingCaption = slides[1]?.overlayText ?? slides[0]?.imagePrompt ?? "See the full story";
|
|
900
|
+
return {
|
|
901
|
+
title: truncateText(leadCaption, 90),
|
|
902
|
+
description: truncateText(`${leadCaption}. ${supportingCaption}.`, 300),
|
|
903
|
+
pinned_comment: truncateText(`Which slide hit hardest: "${leadCaption}" or "${supportingCaption}"?`, 120),
|
|
904
|
+
location: "United States",
|
|
905
|
+
song: "Original Sound",
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
function truncateText(value, maxLength) {
|
|
909
|
+
if (value.length <= maxLength) {
|
|
910
|
+
return value;
|
|
911
|
+
}
|
|
912
|
+
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
913
|
+
}
|
|
914
|
+
async function renderFinishedSlide(imageBytes, overlayText, layout, textStyle) {
|
|
915
|
+
const layers = await buildTextLayers(overlayText, layout, textStyle);
|
|
916
|
+
return sharp(Buffer.from(imageBytes)).composite(layers).png().toBuffer();
|
|
917
|
+
}
|
|
918
|
+
async function buildTextLayers(overlayText, layout, textStyle) {
|
|
919
|
+
const safeWidth = FRAME.width - TIKTOK_SAFE_AREA.left - TIKTOK_SAFE_AREA.right;
|
|
920
|
+
const blockWidth = Math.round(safeWidth * clamp(layout.maxWidthPercent / 100, 0.4, 0.62));
|
|
921
|
+
const { fontSize, lines, lineHeight } = fitLinesToBlock(overlayText, blockWidth, textStyle.fontId);
|
|
922
|
+
const blockHeight = Math.round(lines.length * lineHeight + 10);
|
|
923
|
+
const { left: x, top: y } = resolveTextBlockRect(layout, blockWidth, blockHeight);
|
|
924
|
+
const textBlock = await renderMultilineTextBlock({
|
|
925
|
+
lines,
|
|
926
|
+
blockWidth,
|
|
927
|
+
blockHeight,
|
|
928
|
+
lineHeight,
|
|
929
|
+
fontSize,
|
|
930
|
+
fontFamily: textStyle.fontFamily,
|
|
931
|
+
fontWeight: textStyle.fontWeight,
|
|
932
|
+
fontPath: textStyle.fontPath,
|
|
933
|
+
color: textStyle.fill,
|
|
934
|
+
align: layout.align,
|
|
935
|
+
});
|
|
936
|
+
const rawLayers = [];
|
|
937
|
+
if (textStyle.backgroundColorHex) {
|
|
938
|
+
for (const metric of textBlock.lineMetrics) {
|
|
939
|
+
const chip = await buildTextChip({
|
|
940
|
+
width: Math.ceil(metric.width),
|
|
941
|
+
height: lineHeight,
|
|
942
|
+
fill: textStyle.backgroundColorHex,
|
|
943
|
+
});
|
|
944
|
+
rawLayers.push({
|
|
945
|
+
input: chip,
|
|
946
|
+
left: x + Math.round(metric.left) - TEXT_CHIP_PADDING_X,
|
|
947
|
+
top: y + metric.index * lineHeight - TEXT_CHIP_PADDING_Y,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const shadowLayers = await Promise.all(NATIVE_SHADOW_BLURS.map(async (blur) => ({
|
|
952
|
+
input: await renderShadowLayer({
|
|
953
|
+
textBuffer: textBlock.buffer,
|
|
954
|
+
width: blockWidth,
|
|
955
|
+
height: blockHeight,
|
|
956
|
+
shadowPadding: NATIVE_SHADOW_PADDING,
|
|
957
|
+
blur,
|
|
958
|
+
}),
|
|
959
|
+
left: x - NATIVE_SHADOW_PADDING,
|
|
960
|
+
top: y - NATIVE_SHADOW_PADDING,
|
|
961
|
+
})));
|
|
962
|
+
rawLayers.push(...shadowLayers, { input: textBlock.buffer, left: x, top: y });
|
|
963
|
+
const clippedLayers = await Promise.all(rawLayers.map((layer) => clipCompositeLayer(layer)));
|
|
964
|
+
return clippedLayers.filter((layer) => Boolean(layer));
|
|
965
|
+
}
|
|
966
|
+
function fitLinesToBlock(text, blockWidth, fontId) {
|
|
967
|
+
let fontSize = pickFontSize(text, fontId);
|
|
968
|
+
let lineHeight = pickLineHeight(fontSize, fontId);
|
|
969
|
+
let maxChars = approximateMaxChars(blockWidth, fontSize);
|
|
970
|
+
let lines = wrapText(text, maxChars);
|
|
971
|
+
while ((lines.length > 6 || longestLine(lines) > maxChars + 1) &&
|
|
972
|
+
fontSize > 34) {
|
|
973
|
+
fontSize -= 3;
|
|
974
|
+
lineHeight = pickLineHeight(fontSize, fontId);
|
|
975
|
+
maxChars = approximateMaxChars(blockWidth, fontSize);
|
|
976
|
+
lines = wrapText(text, maxChars);
|
|
977
|
+
}
|
|
978
|
+
return { fontSize, lines, lineHeight };
|
|
979
|
+
}
|
|
980
|
+
function pickFontSize(text, fontId) {
|
|
981
|
+
if (fontId === "yesteryear") {
|
|
982
|
+
if (text.length <= 22) {
|
|
983
|
+
return 74;
|
|
984
|
+
}
|
|
985
|
+
if (text.length <= 38) {
|
|
986
|
+
return 64;
|
|
987
|
+
}
|
|
988
|
+
return 54;
|
|
989
|
+
}
|
|
990
|
+
if (fontId === "georgia_bold") {
|
|
991
|
+
if (text.length <= 22) {
|
|
992
|
+
return 66;
|
|
993
|
+
}
|
|
994
|
+
if (text.length <= 38) {
|
|
995
|
+
return 58;
|
|
996
|
+
}
|
|
997
|
+
return 48;
|
|
998
|
+
}
|
|
999
|
+
if (text.length <= 22) {
|
|
1000
|
+
return 58;
|
|
1001
|
+
}
|
|
1002
|
+
if (text.length <= 38) {
|
|
1003
|
+
return 52;
|
|
1004
|
+
}
|
|
1005
|
+
if (text.length <= 62) {
|
|
1006
|
+
return 46;
|
|
1007
|
+
}
|
|
1008
|
+
if (text.length <= 96) {
|
|
1009
|
+
return 40;
|
|
1010
|
+
}
|
|
1011
|
+
return 36;
|
|
1012
|
+
}
|
|
1013
|
+
function pickLineHeight(fontSize, fontId) {
|
|
1014
|
+
if (fontId === "yesteryear") {
|
|
1015
|
+
return Math.round(fontSize * 1.04);
|
|
1016
|
+
}
|
|
1017
|
+
if (fontId === "georgia_bold") {
|
|
1018
|
+
return Math.round(fontSize * 1.08);
|
|
1019
|
+
}
|
|
1020
|
+
return Math.round(fontSize * 1.12);
|
|
1021
|
+
}
|
|
1022
|
+
function approximateMaxChars(blockWidth, fontSize) {
|
|
1023
|
+
return clamp(Math.floor(blockWidth / Math.max(20, fontSize * 0.42)), 12, 34);
|
|
1024
|
+
}
|
|
1025
|
+
async function renderMultilineTextBlock(input) {
|
|
1026
|
+
if (input.fontPath) {
|
|
1027
|
+
return renderMultilineTextPathBlock({
|
|
1028
|
+
...input,
|
|
1029
|
+
fontPath: input.fontPath,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
const markupSafe = input.lines.map((line) => escapePango(line)).join("\n");
|
|
1033
|
+
const buffer = await sharp({
|
|
1034
|
+
text: {
|
|
1035
|
+
text: `<span foreground="${input.color}">${markupSafe}</span>`,
|
|
1036
|
+
rgba: true,
|
|
1037
|
+
width: input.blockWidth,
|
|
1038
|
+
height: input.blockHeight,
|
|
1039
|
+
align: input.align,
|
|
1040
|
+
spacing: 0,
|
|
1041
|
+
font: `${input.fontFamily} ${input.fontWeight} ${input.fontSize}`,
|
|
1042
|
+
},
|
|
1043
|
+
})
|
|
1044
|
+
.png()
|
|
1045
|
+
.toBuffer();
|
|
1046
|
+
return {
|
|
1047
|
+
buffer,
|
|
1048
|
+
lineMetrics: estimateTextLineMetrics(input.lines, input.blockWidth, input.fontSize, input.align),
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
async function renderMultilineTextPathBlock(input) {
|
|
1052
|
+
const font = loadOverlayFont(input.fontPath);
|
|
1053
|
+
const canvasWidth = input.blockWidth;
|
|
1054
|
+
const canvasHeight = input.blockHeight;
|
|
1055
|
+
const baseline = Math.round(input.fontSize * 0.9);
|
|
1056
|
+
const scale = input.fontSize / font.unitsPerEm;
|
|
1057
|
+
const lineMetrics = [];
|
|
1058
|
+
const pathData = input.lines
|
|
1059
|
+
.map((line, lineIndex) => {
|
|
1060
|
+
const run = font.layout(line);
|
|
1061
|
+
const width = run.positions.reduce((sum, position) => sum + position.xAdvance * scale, 0);
|
|
1062
|
+
const left = input.align === "center"
|
|
1063
|
+
? Math.max(0, (input.blockWidth - width) / 2)
|
|
1064
|
+
: input.align === "right"
|
|
1065
|
+
? Math.max(0, input.blockWidth - width)
|
|
1066
|
+
: 0;
|
|
1067
|
+
lineMetrics.push({ index: lineIndex, left, width });
|
|
1068
|
+
let penX = left;
|
|
1069
|
+
const lineBaseline = baseline + lineIndex * input.lineHeight;
|
|
1070
|
+
return run.glyphs
|
|
1071
|
+
.map((glyph, glyphIndex) => {
|
|
1072
|
+
const position = run.positions[glyphIndex];
|
|
1073
|
+
const x = penX + position.xOffset * scale;
|
|
1074
|
+
const y = lineBaseline - position.yOffset * scale;
|
|
1075
|
+
penX += position.xAdvance * scale;
|
|
1076
|
+
return `<path transform="translate(${roundSvgNumber(x)} ${roundSvgNumber(y)}) scale(${roundSvgNumber(scale)} ${roundSvgNumber(-scale)})" d="${glyph.path.toSVG()}"/>`;
|
|
1077
|
+
})
|
|
1078
|
+
.join("");
|
|
1079
|
+
})
|
|
1080
|
+
.join("");
|
|
1081
|
+
const svg = `<svg width="${canvasWidth}" height="${canvasHeight}" viewBox="0 0 ${canvasWidth} ${canvasHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
1082
|
+
<g fill="${input.color}">${pathData}</g>
|
|
1083
|
+
</svg>`;
|
|
1084
|
+
return {
|
|
1085
|
+
buffer: await sharp(Buffer.from(svg)).png().toBuffer(),
|
|
1086
|
+
lineMetrics,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function estimateTextLineMetrics(lines, blockWidth, fontSize, align) {
|
|
1090
|
+
return lines.map((line, index) => {
|
|
1091
|
+
const width = Math.min(blockWidth, Math.max(fontSize, line.length * fontSize * 0.48));
|
|
1092
|
+
const left = align === "center"
|
|
1093
|
+
? Math.max(0, (blockWidth - width) / 2)
|
|
1094
|
+
: align === "right"
|
|
1095
|
+
? Math.max(0, blockWidth - width)
|
|
1096
|
+
: 0;
|
|
1097
|
+
return { index, left, width };
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
function loadOverlayFont(fontPath) {
|
|
1101
|
+
const cached = parsedFontCache.get(fontPath);
|
|
1102
|
+
if (cached) {
|
|
1103
|
+
return cached;
|
|
1104
|
+
}
|
|
1105
|
+
const loaded = fontkit.openSync(fontPath);
|
|
1106
|
+
const font = loaded.type === "TTC" || loaded.type === "DFont"
|
|
1107
|
+
? loaded.fonts[0]
|
|
1108
|
+
: loaded;
|
|
1109
|
+
if (!font) {
|
|
1110
|
+
throw new Error(`Could not load overlay font from ${fontPath}.`);
|
|
1111
|
+
}
|
|
1112
|
+
parsedFontCache.set(fontPath, font);
|
|
1113
|
+
return font;
|
|
1114
|
+
}
|
|
1115
|
+
function roundSvgNumber(value) {
|
|
1116
|
+
return Number(value.toFixed(4));
|
|
1117
|
+
}
|
|
1118
|
+
async function renderShadowLayer(input) {
|
|
1119
|
+
return sharp({
|
|
1120
|
+
create: {
|
|
1121
|
+
width: input.width + input.shadowPadding * 2,
|
|
1122
|
+
height: input.height + input.shadowPadding * 2,
|
|
1123
|
+
channels: 4,
|
|
1124
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
1125
|
+
},
|
|
1126
|
+
})
|
|
1127
|
+
.composite([
|
|
1128
|
+
{
|
|
1129
|
+
input: input.textBuffer,
|
|
1130
|
+
left: input.shadowPadding,
|
|
1131
|
+
top: input.shadowPadding,
|
|
1132
|
+
},
|
|
1133
|
+
])
|
|
1134
|
+
.modulate({ brightness: 0 })
|
|
1135
|
+
.linear(0, 0)
|
|
1136
|
+
.blur(input.blur)
|
|
1137
|
+
.png()
|
|
1138
|
+
.toBuffer();
|
|
1139
|
+
}
|
|
1140
|
+
async function buildTextChip(input) {
|
|
1141
|
+
const chipWidth = input.width + TEXT_CHIP_PADDING_X * 2;
|
|
1142
|
+
const chipHeight = input.height + TEXT_CHIP_PADDING_Y * 2;
|
|
1143
|
+
return sharp(Buffer.from(`<svg width="${chipWidth}" height="${chipHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
1144
|
+
<rect x="0" y="0" width="${chipWidth}" height="${chipHeight}" rx="${TEXT_CHIP_RADIUS}" ry="${TEXT_CHIP_RADIUS}" fill="${input.fill}" fill-opacity="${TEXT_CHIP_OPACITY}"/>
|
|
1145
|
+
</svg>`))
|
|
1146
|
+
.png()
|
|
1147
|
+
.toBuffer();
|
|
1148
|
+
}
|
|
1149
|
+
function wrapText(text, maxChars) {
|
|
1150
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
1151
|
+
const lines = [];
|
|
1152
|
+
let current = "";
|
|
1153
|
+
for (const word of words) {
|
|
1154
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
1155
|
+
if (candidate.length <= maxChars || !current) {
|
|
1156
|
+
current = candidate;
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
lines.push(current);
|
|
1160
|
+
current = word;
|
|
1161
|
+
}
|
|
1162
|
+
if (current) {
|
|
1163
|
+
lines.push(current);
|
|
1164
|
+
}
|
|
1165
|
+
return lines.length ? lines : [text];
|
|
1166
|
+
}
|
|
1167
|
+
function longestLine(lines) {
|
|
1168
|
+
return lines.reduce((longest, line) => Math.max(longest, line.length), 0);
|
|
1169
|
+
}
|
|
1170
|
+
async function portraitScore(input) {
|
|
1171
|
+
const metadata = await sharp(Buffer.from(input)).rotate().metadata();
|
|
1172
|
+
const width = metadata.width ?? FRAME.width;
|
|
1173
|
+
const height = metadata.height ?? FRAME.height;
|
|
1174
|
+
const aspect = width / height;
|
|
1175
|
+
const thumbnailWidth = 96;
|
|
1176
|
+
const thumbnailHeight = 170;
|
|
1177
|
+
const sample = await sharp(Buffer.from(input))
|
|
1178
|
+
.rotate()
|
|
1179
|
+
.resize(thumbnailWidth, thumbnailHeight, { fit: "fill" })
|
|
1180
|
+
.grayscale()
|
|
1181
|
+
.raw()
|
|
1182
|
+
.toBuffer();
|
|
1183
|
+
const edgeContrast = edgeBandContrast(sample, thumbnailWidth, thumbnailHeight);
|
|
1184
|
+
const edgeFlatnessPenalty = edgeBandFlatnessPenalty(sample, thumbnailWidth, thumbnailHeight);
|
|
1185
|
+
return Math.abs(aspect - 9 / 16) + edgeContrast + edgeFlatnessPenalty;
|
|
1186
|
+
}
|
|
1187
|
+
async function chooseLayoutFromImage(imageBytes, overlayText) {
|
|
1188
|
+
const thumbnailWidth = 108;
|
|
1189
|
+
const thumbnailHeight = 192;
|
|
1190
|
+
const sample = await sharp(Buffer.from(imageBytes))
|
|
1191
|
+
.resize(thumbnailWidth, thumbnailHeight, { fit: "fill" })
|
|
1192
|
+
.grayscale()
|
|
1193
|
+
.raw()
|
|
1194
|
+
.toBuffer();
|
|
1195
|
+
const candidates = buildLayoutCandidates(overlayText);
|
|
1196
|
+
let best = defaultLayout();
|
|
1197
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
1198
|
+
for (const candidate of candidates) {
|
|
1199
|
+
const estimate = estimateTextBlock(overlayText, candidate);
|
|
1200
|
+
const region = regionForLayout(candidate, estimate.blockWidth, estimate.blockHeight);
|
|
1201
|
+
const score = scoreLayoutRegion(sample, thumbnailWidth, thumbnailHeight, region) +
|
|
1202
|
+
candidate.stylePenalty;
|
|
1203
|
+
if (score < bestScore) {
|
|
1204
|
+
best = candidate;
|
|
1205
|
+
bestScore = score;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return {
|
|
1209
|
+
zone: best.zone,
|
|
1210
|
+
align: best.align,
|
|
1211
|
+
maxWidthPercent: best.maxWidthPercent,
|
|
1212
|
+
anchorXPercent: best.anchorXPercent,
|
|
1213
|
+
anchorYPercent: best.anchorYPercent,
|
|
1214
|
+
justification: `${best.justification} Selected by local safe-zone scoring to minimize busy backgrounds and avoid TikTok UI chrome.`,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
async function chooseSlideLayout(ctx, input) {
|
|
1218
|
+
if (input.imageUrl) {
|
|
1219
|
+
try {
|
|
1220
|
+
const analysis = await ctx.providers.analyzeImageLayout({
|
|
1221
|
+
provider: input.provider,
|
|
1222
|
+
model: input.model,
|
|
1223
|
+
imageUrl: input.imageUrl,
|
|
1224
|
+
overlayText: input.overlayText,
|
|
1225
|
+
});
|
|
1226
|
+
return normalizeAiLayout(analysis);
|
|
1227
|
+
}
|
|
1228
|
+
catch (error) {
|
|
1229
|
+
ctx.logger.warn("AI layout analysis failed, falling back to local scorer", {
|
|
1230
|
+
provider: input.provider,
|
|
1231
|
+
model: input.model,
|
|
1232
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return chooseLayoutFromImage(input.imageBytes, input.overlayText);
|
|
1237
|
+
}
|
|
1238
|
+
function normalizeAiLayout(input) {
|
|
1239
|
+
const normalizedAlign = input.align === "right" ? "center" : input.align;
|
|
1240
|
+
const anchorXPercent = normalizedAlign === "center"
|
|
1241
|
+
? 0.5
|
|
1242
|
+
: normalizedAlign === "left"
|
|
1243
|
+
? 0.38
|
|
1244
|
+
: 0.62;
|
|
1245
|
+
const anchorYPercent = input.zone === "top" ? 0.18 : input.zone === "bottom" ? 0.38 : 0.28;
|
|
1246
|
+
return {
|
|
1247
|
+
zone: input.zone === "bottom" ? "center" : input.zone,
|
|
1248
|
+
align: normalizedAlign,
|
|
1249
|
+
maxWidthPercent: clamp(input.maxWidthPercent, 46, 62),
|
|
1250
|
+
anchorXPercent,
|
|
1251
|
+
anchorYPercent,
|
|
1252
|
+
justification: `${input.justification} Normalized toward centered TikTok-native caption placement.`,
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
function estimateTextBlock(text, layout) {
|
|
1256
|
+
const safeWidth = FRAME.width - TIKTOK_SAFE_AREA.left - TIKTOK_SAFE_AREA.right;
|
|
1257
|
+
const centeredBounds = resolveNativeUiTextPaddingBounds();
|
|
1258
|
+
const availableWidth = layout.align === "center"
|
|
1259
|
+
? FRAME.width - centeredBounds.minLeft * 2
|
|
1260
|
+
: safeWidth;
|
|
1261
|
+
const blockWidth = Math.round(availableWidth * clamp(layout.maxWidthPercent / 100, 0.4, 0.62));
|
|
1262
|
+
const { fontSize, lines, lineHeight } = fitLinesToBlock(text, blockWidth, "montserrat");
|
|
1263
|
+
return {
|
|
1264
|
+
blockWidth,
|
|
1265
|
+
blockHeight: Math.round(lines.length * lineHeight + 10),
|
|
1266
|
+
fontSize,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function regionForLayout(layout, blockWidth, blockHeight) {
|
|
1270
|
+
return resolveTextBlockRect(layout, blockWidth, blockHeight);
|
|
1271
|
+
}
|
|
1272
|
+
function resolveTextBlockRect(layout, blockWidth, blockHeight) {
|
|
1273
|
+
const safeWidth = FRAME.width - TIKTOK_SAFE_AREA.left - TIKTOK_SAFE_AREA.right;
|
|
1274
|
+
const safeHeight = FRAME.height - TIKTOK_SAFE_AREA.top - TIKTOK_SAFE_AREA.bottom;
|
|
1275
|
+
const centeredBounds = resolveNativeUiTextPaddingBounds(blockWidth);
|
|
1276
|
+
if (layout.anchorXPercent !== undefined &&
|
|
1277
|
+
layout.anchorYPercent !== undefined) {
|
|
1278
|
+
const centeredX = clamp(Math.round((FRAME.width - blockWidth) / 2), centeredBounds.minLeft, centeredBounds.maxLeft);
|
|
1279
|
+
return {
|
|
1280
|
+
left: layout.align === "center"
|
|
1281
|
+
? centeredX
|
|
1282
|
+
: TIKTOK_SAFE_AREA.left +
|
|
1283
|
+
Math.round((safeWidth - blockWidth) * clamp(layout.anchorXPercent, 0, 1)),
|
|
1284
|
+
top: TIKTOK_SAFE_AREA.top +
|
|
1285
|
+
Math.round((safeHeight - blockHeight) * clamp(layout.anchorYPercent, 0, 1)),
|
|
1286
|
+
width: blockWidth,
|
|
1287
|
+
height: blockHeight,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
const x = layout.align === "left"
|
|
1291
|
+
? TIKTOK_SAFE_AREA.left
|
|
1292
|
+
: layout.align === "center"
|
|
1293
|
+
? clamp(Math.round((FRAME.width - blockWidth) / 2), centeredBounds.minLeft, centeredBounds.maxLeft)
|
|
1294
|
+
: FRAME.width - TIKTOK_SAFE_AREA.right - blockWidth;
|
|
1295
|
+
const y = layout.zone === "top"
|
|
1296
|
+
? TIKTOK_SAFE_AREA.top + 72
|
|
1297
|
+
: layout.zone === "center"
|
|
1298
|
+
? TIKTOK_SAFE_AREA.top + Math.round(safeHeight * 0.2)
|
|
1299
|
+
: FRAME.height - TIKTOK_SAFE_AREA.bottom - blockHeight - 120;
|
|
1300
|
+
return {
|
|
1301
|
+
left: x,
|
|
1302
|
+
top: y,
|
|
1303
|
+
width: blockWidth,
|
|
1304
|
+
height: blockHeight,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
function resolveNativeUiTextPaddingBounds(blockWidth = 0) {
|
|
1308
|
+
const horizontalPadding = Math.round(FRAME.width *
|
|
1309
|
+
((NATIVE_UI_TEXT_ZONE.maxCenterXPercent -
|
|
1310
|
+
NATIVE_UI_TEXT_ZONE.minCenterXPercent) /
|
|
1311
|
+
2));
|
|
1312
|
+
return {
|
|
1313
|
+
minLeft: horizontalPadding,
|
|
1314
|
+
maxLeft: Math.max(horizontalPadding, FRAME.width - horizontalPadding - blockWidth),
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
function scoreLayoutRegion(sample, width, height, region) {
|
|
1318
|
+
const inner = sampleStats(sample, width, height, region, 0);
|
|
1319
|
+
const outer = sampleStats(sample, width, height, region, 44);
|
|
1320
|
+
const halo = sampleStats(sample, width, height, region, 110);
|
|
1321
|
+
const edgePenalty = (region.left < 112 ? 6 : 0) +
|
|
1322
|
+
(region.top < 340 ? 18 : 0) +
|
|
1323
|
+
(region.top + region.height > FRAME.height - 560 ? 18 : 0);
|
|
1324
|
+
const readabilityPenalty = Math.max(0, inner.mean - 205) * 0.24;
|
|
1325
|
+
const lowContrastPenalty = Math.abs(inner.mean - outer.mean) < 10 ? 6 : 0;
|
|
1326
|
+
const haloPenalty = halo.detail * 0.35 + halo.variance * 0.01;
|
|
1327
|
+
return (inner.detail * 1.35 +
|
|
1328
|
+
inner.variance * 0.022 +
|
|
1329
|
+
outer.detail * 0.45 +
|
|
1330
|
+
readabilityPenalty +
|
|
1331
|
+
lowContrastPenalty +
|
|
1332
|
+
haloPenalty +
|
|
1333
|
+
edgePenalty);
|
|
1334
|
+
}
|
|
1335
|
+
function sampleStats(sample, width, height, region, padding) {
|
|
1336
|
+
const left = clamp(Math.floor((region.left / FRAME.width) * width), 0, width - 1);
|
|
1337
|
+
const top = clamp(Math.floor((region.top / FRAME.height) * height), 0, height - 1);
|
|
1338
|
+
const right = clamp(Math.ceil(((region.left + region.width) / FRAME.width) * width), left + 1, width);
|
|
1339
|
+
const bottom = clamp(Math.ceil(((region.top + region.height) / FRAME.height) * height), top + 1, height);
|
|
1340
|
+
const padX = Math.round((padding / FRAME.width) * width);
|
|
1341
|
+
const padY = Math.round((padding / FRAME.height) * height);
|
|
1342
|
+
const paddedLeft = clamp(left - padX, 0, width - 1);
|
|
1343
|
+
const paddedTop = clamp(top - padY, 0, height - 1);
|
|
1344
|
+
const paddedRight = clamp(right + padX, paddedLeft + 1, width);
|
|
1345
|
+
const paddedBottom = clamp(bottom + padY, paddedTop + 1, height);
|
|
1346
|
+
let total = 0;
|
|
1347
|
+
let totalSq = 0;
|
|
1348
|
+
let detail = 0;
|
|
1349
|
+
let count = 0;
|
|
1350
|
+
for (let y = paddedTop; y < paddedBottom; y += 1) {
|
|
1351
|
+
for (let x = paddedLeft; x < paddedRight; x += 1) {
|
|
1352
|
+
if (padding > 0 && x >= left && x < right && y >= top && y < bottom) {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
const index = y * width + x;
|
|
1356
|
+
const current = sample[index] ?? 0;
|
|
1357
|
+
const leftPixel = x > 0 ? (sample[index - 1] ?? current) : current;
|
|
1358
|
+
const upPixel = y > 0 ? (sample[index - width] ?? current) : current;
|
|
1359
|
+
total += current;
|
|
1360
|
+
totalSq += current * current;
|
|
1361
|
+
detail += Math.abs(current - leftPixel) + Math.abs(current - upPixel);
|
|
1362
|
+
count += 1;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
const mean = total / Math.max(count, 1);
|
|
1366
|
+
const variance = Math.max(0, totalSq / Math.max(count, 1) - mean * mean);
|
|
1367
|
+
return {
|
|
1368
|
+
mean,
|
|
1369
|
+
variance,
|
|
1370
|
+
detail: detail / Math.max(count, 1),
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function edgeBandContrast(sample, width, height) {
|
|
1374
|
+
const bandHeight = Math.max(8, Math.floor(height * 0.16));
|
|
1375
|
+
const top = bandActivity(sample, width, height, 0, bandHeight);
|
|
1376
|
+
const bottom = bandActivity(sample, width, height, height - bandHeight, height);
|
|
1377
|
+
return (top + bottom) / 220;
|
|
1378
|
+
}
|
|
1379
|
+
function edgeBandFlatnessPenalty(sample, width, height) {
|
|
1380
|
+
const bandHeight = Math.max(8, Math.floor(height * 0.16));
|
|
1381
|
+
const centerStart = Math.max(0, Math.floor(height * 0.34));
|
|
1382
|
+
const centerEnd = Math.min(height, Math.ceil(height * 0.66));
|
|
1383
|
+
const top = bandActivity(sample, width, height, 0, bandHeight);
|
|
1384
|
+
const bottom = bandActivity(sample, width, height, height - bandHeight, height);
|
|
1385
|
+
const center = bandActivity(sample, width, height, centerStart, centerEnd);
|
|
1386
|
+
const edgeAverage = (top + bottom) / 2;
|
|
1387
|
+
if (center <= 0) {
|
|
1388
|
+
return 0;
|
|
1389
|
+
}
|
|
1390
|
+
const ratio = edgeAverage / center;
|
|
1391
|
+
if (ratio >= 0.72) {
|
|
1392
|
+
return 0;
|
|
1393
|
+
}
|
|
1394
|
+
return (0.72 - ratio) * 3.4;
|
|
1395
|
+
}
|
|
1396
|
+
function bandActivity(sample, width, height, startY, endY) {
|
|
1397
|
+
let detail = 0;
|
|
1398
|
+
let count = 0;
|
|
1399
|
+
for (let y = startY; y < endY; y += 1) {
|
|
1400
|
+
for (let x = 1; x < width; x += 1) {
|
|
1401
|
+
const index = y * width + x;
|
|
1402
|
+
const current = sample[index] ?? 0;
|
|
1403
|
+
const prev = sample[index - 1] ?? current;
|
|
1404
|
+
detail += Math.abs(current - prev);
|
|
1405
|
+
count += 1;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return detail / Math.max(count, 1);
|
|
1409
|
+
}
|
|
1410
|
+
function clamp(value, min, max) {
|
|
1411
|
+
return Math.min(Math.max(value, min), max);
|
|
1412
|
+
}
|
|
1413
|
+
function escapePango(text) {
|
|
1414
|
+
return text
|
|
1415
|
+
.replaceAll("&", "&")
|
|
1416
|
+
.replaceAll("<", "<")
|
|
1417
|
+
.replaceAll(">", ">")
|
|
1418
|
+
.replaceAll('"', """);
|
|
1419
|
+
}
|
|
1420
|
+
async function clipCompositeLayer(layer) {
|
|
1421
|
+
const metadata = await sharp(layer.input).metadata();
|
|
1422
|
+
const width = metadata.width ?? 0;
|
|
1423
|
+
const height = metadata.height ?? 0;
|
|
1424
|
+
if (!width || !height) {
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
const left = Math.max(0, layer.left);
|
|
1428
|
+
const top = Math.max(0, layer.top);
|
|
1429
|
+
const cropLeft = Math.max(0, -layer.left);
|
|
1430
|
+
const cropTop = Math.max(0, -layer.top);
|
|
1431
|
+
const availableWidth = FRAME.width - left;
|
|
1432
|
+
const availableHeight = FRAME.height - top;
|
|
1433
|
+
const cropWidth = Math.min(width - cropLeft, availableWidth);
|
|
1434
|
+
const cropHeight = Math.min(height - cropTop, availableHeight);
|
|
1435
|
+
if (cropWidth <= 0 || cropHeight <= 0) {
|
|
1436
|
+
return null;
|
|
1437
|
+
}
|
|
1438
|
+
if (cropLeft === 0 &&
|
|
1439
|
+
cropTop === 0 &&
|
|
1440
|
+
cropWidth === width &&
|
|
1441
|
+
cropHeight === height) {
|
|
1442
|
+
return {
|
|
1443
|
+
input: layer.input,
|
|
1444
|
+
left,
|
|
1445
|
+
top,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
const input = await sharp(layer.input)
|
|
1449
|
+
.extract({
|
|
1450
|
+
left: cropLeft,
|
|
1451
|
+
top: cropTop,
|
|
1452
|
+
width: cropWidth,
|
|
1453
|
+
height: cropHeight,
|
|
1454
|
+
})
|
|
1455
|
+
.png()
|
|
1456
|
+
.toBuffer();
|
|
1457
|
+
return {
|
|
1458
|
+
input,
|
|
1459
|
+
left,
|
|
1460
|
+
top,
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
function buildLayoutCandidates(overlayText) {
|
|
1464
|
+
const prefersCenteredText = overlayText.trim().length <= 42;
|
|
1465
|
+
const widthPercents = prefersCenteredText ? [48, 52, 56] : [50, 54, 58];
|
|
1466
|
+
const centerAnchors = prefersCenteredText
|
|
1467
|
+
? [0.44, 0.5, 0.56]
|
|
1468
|
+
: [0.42, 0.5, 0.58];
|
|
1469
|
+
const yAnchors = [0.16, 0.22, 0.28, 0.34, 0.4];
|
|
1470
|
+
const candidates = [];
|
|
1471
|
+
for (const maxWidthPercent of widthPercents) {
|
|
1472
|
+
for (const anchorYPercent of yAnchors) {
|
|
1473
|
+
for (const anchorXPercent of centerAnchors) {
|
|
1474
|
+
candidates.push({
|
|
1475
|
+
zone: anchorYPercent < 0.22 ? "top" : "center",
|
|
1476
|
+
align: "center",
|
|
1477
|
+
maxWidthPercent,
|
|
1478
|
+
anchorXPercent,
|
|
1479
|
+
anchorYPercent,
|
|
1480
|
+
justification: "Image-aware centered native TikTok caption zone.",
|
|
1481
|
+
stylePenalty: scoreNativeUiAnchor(anchorXPercent, anchorYPercent) +
|
|
1482
|
+
(prefersCenteredText ? 0 : 4),
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return candidates;
|
|
1488
|
+
}
|
|
1489
|
+
function scoreNativeUiAnchor(anchorXPercent, anchorYPercent) {
|
|
1490
|
+
let penalty = 0;
|
|
1491
|
+
if (anchorXPercent < NATIVE_UI_TEXT_ZONE.minCenterXPercent ||
|
|
1492
|
+
anchorXPercent > NATIVE_UI_TEXT_ZONE.maxCenterXPercent) {
|
|
1493
|
+
penalty += 10;
|
|
1494
|
+
}
|
|
1495
|
+
if (anchorYPercent < NATIVE_UI_TEXT_ZONE.minCenterYPercent ||
|
|
1496
|
+
anchorYPercent > NATIVE_UI_TEXT_ZONE.maxCenterYPercent) {
|
|
1497
|
+
penalty += 10;
|
|
1498
|
+
}
|
|
1499
|
+
penalty += Math.abs(anchorXPercent - 0.5) * 24;
|
|
1500
|
+
penalty += Math.abs(anchorYPercent - 0.28) * 18;
|
|
1501
|
+
return penalty;
|
|
1502
|
+
}
|
|
1503
|
+
function resolveRemotionEntryPoint() {
|
|
1504
|
+
const builtPath = fileURLToPath(new URL("./remotion/index.js", import.meta.url));
|
|
1505
|
+
if (existsSync(builtPath)) {
|
|
1506
|
+
return builtPath;
|
|
1507
|
+
}
|
|
1508
|
+
return fileURLToPath(new URL("./remotion/index.tsx", import.meta.url));
|
|
1509
|
+
}
|
|
1510
|
+
function resolveTemplateRuntimeConfig() {
|
|
1511
|
+
const candidates = [
|
|
1512
|
+
fileURLToPath(new URL("../template.config.json", import.meta.url)),
|
|
1513
|
+
path.resolve(process.cwd(), "templates/vidfarm_template_0000/template.config.json"),
|
|
1514
|
+
];
|
|
1515
|
+
for (const candidate of candidates) {
|
|
1516
|
+
if (!existsSync(candidate)) {
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
return JSON.parse(readFileSync(candidate, "utf8"));
|
|
1520
|
+
}
|
|
1521
|
+
return {};
|
|
1522
|
+
}
|
|
1523
|
+
function resolveSkillPath() {
|
|
1524
|
+
const builtPath = fileURLToPath(new URL("../SKILL.md", import.meta.url));
|
|
1525
|
+
if (existsSync(builtPath)) {
|
|
1526
|
+
return builtPath;
|
|
1527
|
+
}
|
|
1528
|
+
return path.resolve(process.cwd(), "templates/vidfarm_template_0000/SKILL.md");
|
|
1529
|
+
}
|
|
1530
|
+
function resolveOverlayFonts() {
|
|
1531
|
+
const resolved = Object.fromEntries(TEMPLATE_FONT_OPTIONS.map((option) => {
|
|
1532
|
+
const assetFile = "assetFile" in option ? option.assetFile : undefined;
|
|
1533
|
+
const fontsourceFile = "fontsourceFile" in option ? option.fontsourceFile : undefined;
|
|
1534
|
+
const candidates = [
|
|
1535
|
+
...(fontsourceFile
|
|
1536
|
+
? [
|
|
1537
|
+
path.resolve(process.cwd(), "node_modules", fontsourceFile),
|
|
1538
|
+
path.resolve(process.cwd(), "templates/vidfarm_template_0000/node_modules", fontsourceFile),
|
|
1539
|
+
]
|
|
1540
|
+
: []),
|
|
1541
|
+
...(assetFile
|
|
1542
|
+
? [
|
|
1543
|
+
fileURLToPath(new URL(`../assets/${assetFile}`, import.meta.url)),
|
|
1544
|
+
path.resolve(process.cwd(), `templates/vidfarm_template_0000/assets/${assetFile}`),
|
|
1545
|
+
]
|
|
1546
|
+
: []),
|
|
1547
|
+
];
|
|
1548
|
+
for (const candidate of candidates) {
|
|
1549
|
+
if (existsSync(candidate)) {
|
|
1550
|
+
return [option.id, { path: candidate }];
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
throw new Error(`Could not resolve local overlay font asset for ${option.label}.`);
|
|
1554
|
+
}));
|
|
1555
|
+
return resolved;
|
|
1556
|
+
}
|
|
1557
|
+
function resolveCustomCaptionFontPath(customPath) {
|
|
1558
|
+
const normalizedPath = customPath.replace(/^\.?\//, "");
|
|
1559
|
+
const candidates = [
|
|
1560
|
+
path.isAbsolute(customPath) ? customPath : null,
|
|
1561
|
+
fileURLToPath(new URL(`../${normalizedPath}`, import.meta.url)),
|
|
1562
|
+
path.resolve(process.cwd(), customPath),
|
|
1563
|
+
path.resolve(process.cwd(), "templates/vidfarm_template_0000", customPath),
|
|
1564
|
+
].filter((candidate) => Boolean(candidate));
|
|
1565
|
+
return candidates.find(existsSync) ?? null;
|
|
1566
|
+
}
|