@mevdragon/vidfarm-devcli 0.1.0 → 0.2.1
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 +12 -5
- package/PLATFORM_SPEC.md +143 -2
- package/README.md +165 -16
- package/SKILL.developer.md +258 -0
- package/SKILL.director.md +599 -0
- package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
- package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
- package/dist/src/account-pages.js +630 -0
- package/dist/src/app.js +897 -66
- package/dist/src/cli.js +284 -5
- package/dist/src/config.js +25 -5
- package/dist/src/context.js +1 -1
- package/dist/src/db.js +427 -18
- package/dist/src/dev-app.js +59 -12
- package/dist/src/homepage.js +441 -0
- package/dist/src/index.js +12 -7
- package/dist/src/lib/crypto.js +14 -0
- package/dist/src/lib/template-dna.js +542 -0
- package/dist/src/lib/template-style-options.js +49 -0
- package/dist/src/registry.js +54 -7
- package/dist/src/runtime.js +3 -1
- package/dist/src/services/auth.js +69 -5
- package/dist/src/services/jobs.js +23 -4
- package/dist/src/services/providers.js +74 -12
- package/dist/src/services/storage.js +74 -18
- package/dist/src/services/template-certification.js +160 -0
- package/dist/src/services/template-loader.js +37 -0
- package/dist/src/services/template-sources.js +135 -0
- package/dist/src/worker.js +19 -7
- package/dist/templates/template_0000/src/lib/images.js +242 -0
- package/dist/templates/template_0000/src/remotion/Root.js +33 -0
- package/dist/templates/template_0000/src/sdk.js +3 -0
- package/dist/templates/template_0000/src/style-options.js +51 -0
- package/dist/templates/template_0000/src/template-dna.js +9 -0
- package/dist/templates/template_0000/src/template.js +1217 -0
- package/package.json +10 -1
- package/templates/template_0000/README.md +121 -0
- package/templates/template_0000/SKILL.md +193 -0
- package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/template_0000/composition.json +11 -0
- package/templates/template_0000/package-lock.json +5505 -0
- package/templates/template_0000/package.json +31 -0
- package/templates/template_0000/research/preview/.gitkeep +1 -0
- package/templates/template_0000/research/source_notes.md +7 -0
- package/templates/template_0000/scripts/create-site.mjs +27 -0
- package/templates/template_0000/scripts/render-cloud.mjs +72 -0
- package/templates/template_0000/src/lib/images.js +242 -0
- package/templates/template_0000/src/lib/images.ts +284 -0
- package/templates/template_0000/src/remotion/Root.js +33 -0
- package/templates/template_0000/src/remotion/Root.tsx +75 -0
- package/templates/template_0000/src/remotion/index.js +3 -0
- package/templates/template_0000/src/remotion/index.tsx +4 -0
- package/templates/template_0000/src/sdk.js +3 -0
- package/templates/template_0000/src/sdk.ts +122 -0
- package/templates/template_0000/src/style-options.js +51 -0
- package/templates/template_0000/src/style-options.ts +60 -0
- package/templates/template_0000/src/template-dna.ts +15 -0
- package/templates/template_0000/src/template.js +1117 -0
- package/templates/template_0000/src/template.ts +1747 -0
- package/templates/template_0000/template.config.json +26 -0
- package/templates/template_0000/tsconfig.json +19 -0
- package/dist/templates/template_0000/demo-template.js +0 -196
- package/dist/templates/template_0000/remotion/Root.js +0 -66
- /package/dist/templates/template_0000/{remotion → src/remotion}/index.js +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
export async function loadTemplateFromModule(modulePath) {
|
|
3
|
+
const loaded = await import(pathToFileURL(modulePath).href);
|
|
4
|
+
const candidates = Object.values(loaded);
|
|
5
|
+
for (const candidate of candidates) {
|
|
6
|
+
if (isTemplateDefinition(candidate)) {
|
|
7
|
+
return candidate;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
throw new Error(`No template definition export found in ${modulePath}`);
|
|
11
|
+
}
|
|
12
|
+
function isTemplateDefinition(value) {
|
|
13
|
+
if (!value || typeof value !== "object") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const candidate = value;
|
|
17
|
+
const about = typeof candidate.about === "object" && candidate.about !== null
|
|
18
|
+
? candidate.about
|
|
19
|
+
: null;
|
|
20
|
+
const previewMedia = about && Array.isArray(about.preview_media)
|
|
21
|
+
? about.preview_media
|
|
22
|
+
: null;
|
|
23
|
+
return (typeof candidate.id === "string" &&
|
|
24
|
+
typeof candidate.slugId === "string" &&
|
|
25
|
+
typeof candidate.version === "string" &&
|
|
26
|
+
about !== null &&
|
|
27
|
+
typeof about.title === "string" &&
|
|
28
|
+
typeof about.description === "string" &&
|
|
29
|
+
typeof about.viral_dna === "string" &&
|
|
30
|
+
typeof about.visual_dna === "string" &&
|
|
31
|
+
Array.isArray(previewMedia) &&
|
|
32
|
+
previewMedia.every((entry) => typeof entry === "string") &&
|
|
33
|
+
typeof about.link_to_original === "string" &&
|
|
34
|
+
typeof candidate.configSchema === "object" &&
|
|
35
|
+
typeof candidate.operations === "object" &&
|
|
36
|
+
typeof candidate.jobs === "object");
|
|
37
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
import { database } from "../db.js";
|
|
7
|
+
import { createId } from "../lib/ids.js";
|
|
8
|
+
import { nowIso } from "../lib/time.js";
|
|
9
|
+
import { loadTemplateFromModule } from "./template-loader.js";
|
|
10
|
+
import { TemplateCertificationService } from "./template-certification.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
export class TemplateSourceService {
|
|
13
|
+
certification = new TemplateCertificationService();
|
|
14
|
+
listSources() {
|
|
15
|
+
return database.listTemplateSources();
|
|
16
|
+
}
|
|
17
|
+
listReleases(templateId) {
|
|
18
|
+
return database.listTemplateReleases(templateId);
|
|
19
|
+
}
|
|
20
|
+
registerSource(input) {
|
|
21
|
+
const existingByTemplateId = database.getTemplateSourceByTemplateId(input.templateId);
|
|
22
|
+
if (existingByTemplateId) {
|
|
23
|
+
throw new Error("A template with this template_id already exists. Generate a new UUIDv4 and try again.");
|
|
24
|
+
}
|
|
25
|
+
const existingBySlugId = database.getTemplateSourceBySlugId(input.slugId);
|
|
26
|
+
if (existingBySlugId) {
|
|
27
|
+
throw new Error(`A template with slug_id ${input.slugId} already exists.`);
|
|
28
|
+
}
|
|
29
|
+
return database.createTemplateSource({
|
|
30
|
+
id: createId("tsrc"),
|
|
31
|
+
templateId: input.templateId,
|
|
32
|
+
slugId: input.slugId,
|
|
33
|
+
repoUrl: input.repoUrl,
|
|
34
|
+
branch: input.branch ?? "production",
|
|
35
|
+
templateModulePath: input.templateModulePath,
|
|
36
|
+
skillPath: input.skillPath ?? "SKILL.md",
|
|
37
|
+
installCommand: input.installCommand ?? "npm install",
|
|
38
|
+
buildCommand: input.buildCommand ?? "npm run build",
|
|
39
|
+
status: "active"
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async importRelease(input) {
|
|
43
|
+
const source = database.getTemplateSource(input.sourceId);
|
|
44
|
+
if (!source) {
|
|
45
|
+
throw new Error("Template source not found.");
|
|
46
|
+
}
|
|
47
|
+
const commitSha = input.commitSha ?? await this.resolveBranchHead(source.repoUrl, source.branch);
|
|
48
|
+
const checkoutPath = path.join(config.TEMPLATE_SOURCE_ROOT, source.templateId, commitSha);
|
|
49
|
+
const skillPath = path.join(checkoutPath, source.skillPath);
|
|
50
|
+
const modulePath = path.join(checkoutPath, source.templateModulePath);
|
|
51
|
+
if (!existsSync(checkoutPath)) {
|
|
52
|
+
mkdirSync(path.dirname(checkoutPath), { recursive: true });
|
|
53
|
+
await this.runShell(["git", "clone", "--branch", source.branch, source.repoUrl, checkoutPath], process.cwd());
|
|
54
|
+
await this.runShell(["git", "checkout", commitSha], checkoutPath);
|
|
55
|
+
if (source.installCommand.trim()) {
|
|
56
|
+
await this.runCommandString(source.installCommand, checkoutPath);
|
|
57
|
+
}
|
|
58
|
+
if (source.buildCommand.trim()) {
|
|
59
|
+
await this.runCommandString(source.buildCommand, checkoutPath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const template = await loadTemplateFromModule(modulePath);
|
|
63
|
+
if (template.id !== source.templateId) {
|
|
64
|
+
throw new Error(`Imported template id ${template.id} does not match source template id ${source.templateId}.`);
|
|
65
|
+
}
|
|
66
|
+
if (template.slugId !== source.slugId) {
|
|
67
|
+
throw new Error(`Imported template slug_id ${template.slugId} does not match source slug_id ${source.slugId}.`);
|
|
68
|
+
}
|
|
69
|
+
const certificationReport = await this.certification.certify({ template, skillPath });
|
|
70
|
+
const status = certificationReport.passed ? "certified" : "failed";
|
|
71
|
+
return database.createTemplateRelease({
|
|
72
|
+
id: createId("trel"),
|
|
73
|
+
sourceId: source.id,
|
|
74
|
+
templateId: source.templateId,
|
|
75
|
+
branch: source.branch,
|
|
76
|
+
commitSha,
|
|
77
|
+
checkoutPath,
|
|
78
|
+
skillPath,
|
|
79
|
+
modulePath,
|
|
80
|
+
status,
|
|
81
|
+
certificationReport,
|
|
82
|
+
activatedAt: null
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async activateRelease(input) {
|
|
86
|
+
const release = database.getTemplateRelease(input.releaseId);
|
|
87
|
+
if (!release) {
|
|
88
|
+
throw new Error("Template release not found.");
|
|
89
|
+
}
|
|
90
|
+
if (release.status !== "certified" && release.status !== "active") {
|
|
91
|
+
throw new Error("Only certified releases can be activated.");
|
|
92
|
+
}
|
|
93
|
+
const template = await loadTemplateFromModule(release.modulePath);
|
|
94
|
+
const certificationReport = release.certificationReport ?? await this.certification.certify({
|
|
95
|
+
template,
|
|
96
|
+
skillPath: release.skillPath
|
|
97
|
+
});
|
|
98
|
+
if (!certificationReport.passed) {
|
|
99
|
+
database.updateTemplateReleaseStatus({
|
|
100
|
+
id: release.id,
|
|
101
|
+
status: "failed",
|
|
102
|
+
certificationReport
|
|
103
|
+
});
|
|
104
|
+
throw new Error("Template release failed certification and cannot be activated.");
|
|
105
|
+
}
|
|
106
|
+
database.clearActiveTemplateReleases(release.templateId);
|
|
107
|
+
database.updateTemplateReleaseStatus({
|
|
108
|
+
id: release.id,
|
|
109
|
+
status: "active",
|
|
110
|
+
certificationReport,
|
|
111
|
+
activatedAt: nowIso()
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
release: database.getTemplateRelease(release.id),
|
|
115
|
+
template: {
|
|
116
|
+
...template,
|
|
117
|
+
skillPath: release.skillPath
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async resolveBranchHead(repoUrl, branch) {
|
|
122
|
+
const { stdout } = await execFileAsync("git", ["ls-remote", repoUrl, branch], { cwd: process.cwd() });
|
|
123
|
+
const line = stdout.trim().split("\n").find(Boolean);
|
|
124
|
+
if (!line) {
|
|
125
|
+
throw new Error(`Unable to resolve branch ${branch} for ${repoUrl}`);
|
|
126
|
+
}
|
|
127
|
+
return line.split(/\s+/)[0];
|
|
128
|
+
}
|
|
129
|
+
async runShell(args, cwd) {
|
|
130
|
+
await execFileAsync(args[0], args.slice(1), { cwd });
|
|
131
|
+
}
|
|
132
|
+
async runCommandString(command, cwd) {
|
|
133
|
+
await execFileAsync("/bin/sh", ["-lc", command], { cwd });
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/src/worker.js
CHANGED
|
@@ -17,7 +17,8 @@ export class Worker {
|
|
|
17
17
|
storage = new StorageService();
|
|
18
18
|
remotion = new RemotionService();
|
|
19
19
|
webhooks = new WebhookService();
|
|
20
|
-
|
|
20
|
+
tickInFlight = false;
|
|
21
|
+
activeJobs = 0;
|
|
21
22
|
start() {
|
|
22
23
|
this.timer = setInterval(() => void this.tick(), config.WORKER_POLL_MS);
|
|
23
24
|
void this.tick();
|
|
@@ -28,22 +29,33 @@ export class Worker {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
async tick() {
|
|
31
|
-
if (this.
|
|
32
|
+
if (this.tickInFlight) {
|
|
32
33
|
return;
|
|
33
34
|
}
|
|
34
|
-
this.
|
|
35
|
+
this.tickInFlight = true;
|
|
35
36
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const availableSlots = Math.max(0, config.WORKER_MAX_CONCURRENT_JOBS - this.activeJobs);
|
|
38
|
+
if (availableSlots > 0) {
|
|
39
|
+
const runnable = database.listRunnableJobs(Math.min(config.WORKER_BATCH_SIZE, availableSlots));
|
|
40
|
+
const executions = runnable.map(async (job) => {
|
|
41
|
+
this.activeJobs += 1;
|
|
42
|
+
try {
|
|
43
|
+
await this.runJob(job.id);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
this.activeJobs -= 1;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
await Promise.all(executions);
|
|
39
50
|
}
|
|
40
51
|
await this.webhooks.flushPending();
|
|
41
52
|
}
|
|
42
53
|
finally {
|
|
43
|
-
this.
|
|
54
|
+
this.tickInFlight = false;
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
async runJob(jobId) {
|
|
58
|
+
await templateRegistry.ensureInitialized();
|
|
47
59
|
const job = this.jobs.getJob(jobId);
|
|
48
60
|
if (!job || job.status !== "queued") {
|
|
49
61
|
return;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
export async function normalizeToPortraitFrame(input, target = { width: 1080, height: 1920 }) {
|
|
3
|
+
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
4
|
+
const oriented = sharp(buffer, { density: 144 }).rotate();
|
|
5
|
+
const trimmed = await trimFlatBorders(oriented);
|
|
6
|
+
const targetAspect = target.width / target.height;
|
|
7
|
+
// If the model already returned a near-native vertical frame, avoid the
|
|
8
|
+
// activity crop pass because it can misread low-detail edges as padding.
|
|
9
|
+
const shouldPreserveFraming = await isCloseToAspect(trimmed, targetAspect, 0.025);
|
|
10
|
+
const cropped = shouldPreserveFraming ? trimmed : await cropToActiveImageRegion(trimmed);
|
|
11
|
+
const portraitCrop = await cropToPortraitAspect(cropped, targetAspect);
|
|
12
|
+
const output = await portraitCrop
|
|
13
|
+
.resize(target.width, target.height, {
|
|
14
|
+
fit: "cover",
|
|
15
|
+
position: sharp.strategy.attention
|
|
16
|
+
})
|
|
17
|
+
.png()
|
|
18
|
+
.toBuffer();
|
|
19
|
+
const exact = await ensureExactPixelSize(output, target);
|
|
20
|
+
return {
|
|
21
|
+
bytes: exact,
|
|
22
|
+
contentType: "image/png",
|
|
23
|
+
width: target.width,
|
|
24
|
+
height: target.height
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function isCloseToAspect(image, targetAspect, tolerance) {
|
|
28
|
+
const metadata = await image.metadata();
|
|
29
|
+
const width = metadata.width ?? 0;
|
|
30
|
+
const height = metadata.height ?? 0;
|
|
31
|
+
if (!width || !height) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return Math.abs(width / height - targetAspect) <= tolerance;
|
|
35
|
+
}
|
|
36
|
+
async function trimFlatBorders(image) {
|
|
37
|
+
const metadata = await image.metadata();
|
|
38
|
+
const sourceWidth = metadata.width ?? 0;
|
|
39
|
+
const sourceHeight = metadata.height ?? 0;
|
|
40
|
+
if (!sourceWidth || !sourceHeight || sourceWidth < 3 || sourceHeight < 3) {
|
|
41
|
+
return image;
|
|
42
|
+
}
|
|
43
|
+
const { data, info } = await image
|
|
44
|
+
.clone()
|
|
45
|
+
.trim({ threshold: 16 })
|
|
46
|
+
.png()
|
|
47
|
+
.toBuffer({ resolveWithObject: true });
|
|
48
|
+
const widthRatio = info.width / sourceWidth;
|
|
49
|
+
const heightRatio = info.height / sourceHeight;
|
|
50
|
+
if (widthRatio > 0.985 && heightRatio > 0.985) {
|
|
51
|
+
return image;
|
|
52
|
+
}
|
|
53
|
+
return sharp(data).rotate();
|
|
54
|
+
}
|
|
55
|
+
async function cropToActiveImageRegion(image) {
|
|
56
|
+
const metadata = await image.metadata();
|
|
57
|
+
const sourceWidth = metadata.width ?? 0;
|
|
58
|
+
const sourceHeight = metadata.height ?? 0;
|
|
59
|
+
if (!sourceWidth || !sourceHeight) {
|
|
60
|
+
return image;
|
|
61
|
+
}
|
|
62
|
+
const sampleWidth = 96;
|
|
63
|
+
const sampleHeight = 170;
|
|
64
|
+
const sample = await image
|
|
65
|
+
.clone()
|
|
66
|
+
.resize(sampleWidth, sampleHeight, { fit: "fill" })
|
|
67
|
+
.grayscale()
|
|
68
|
+
.raw()
|
|
69
|
+
.toBuffer();
|
|
70
|
+
const bounds = detectActiveBounds(sample, sampleWidth, sampleHeight);
|
|
71
|
+
if (!bounds) {
|
|
72
|
+
return image;
|
|
73
|
+
}
|
|
74
|
+
const left = Math.max(0, Math.floor((bounds.left / sampleWidth) * sourceWidth));
|
|
75
|
+
const top = Math.max(0, Math.floor((bounds.top / sampleHeight) * sourceHeight));
|
|
76
|
+
const width = Math.min(sourceWidth - left, Math.max(1, Math.ceil((bounds.width / sampleWidth) * sourceWidth)));
|
|
77
|
+
const height = Math.min(sourceHeight - top, Math.max(1, Math.ceil((bounds.height / sampleHeight) * sourceHeight)));
|
|
78
|
+
return image.extract({ left, top, width, height });
|
|
79
|
+
}
|
|
80
|
+
async function cropToPortraitAspect(image, targetAspect) {
|
|
81
|
+
const metadata = await image.metadata();
|
|
82
|
+
const sourceWidth = metadata.width ?? 0;
|
|
83
|
+
const sourceHeight = metadata.height ?? 0;
|
|
84
|
+
if (!sourceWidth || !sourceHeight) {
|
|
85
|
+
return image;
|
|
86
|
+
}
|
|
87
|
+
const sourceAspect = sourceWidth / sourceHeight;
|
|
88
|
+
if (Math.abs(sourceAspect - targetAspect) < 0.015) {
|
|
89
|
+
return image;
|
|
90
|
+
}
|
|
91
|
+
const focus = await detectFocusPoint(image, sourceWidth, sourceHeight);
|
|
92
|
+
if (sourceAspect > targetAspect) {
|
|
93
|
+
const cropWidth = Math.max(1, Math.min(sourceWidth, Math.round(sourceHeight * targetAspect)));
|
|
94
|
+
const left = clamp(Math.round(focus.x - cropWidth / 2), 0, sourceWidth - cropWidth);
|
|
95
|
+
return image.extract({ left, top: 0, width: cropWidth, height: sourceHeight });
|
|
96
|
+
}
|
|
97
|
+
const cropHeight = Math.max(1, Math.min(sourceHeight, Math.round(sourceWidth / targetAspect)));
|
|
98
|
+
const top = clamp(Math.round(focus.y - cropHeight / 2), 0, sourceHeight - cropHeight);
|
|
99
|
+
return image.extract({ left: 0, top, width: sourceWidth, height: cropHeight });
|
|
100
|
+
}
|
|
101
|
+
function detectActiveBounds(sample, width, height) {
|
|
102
|
+
const rowActivity = new Array(height).fill(0);
|
|
103
|
+
const colActivity = new Array(width).fill(0);
|
|
104
|
+
for (let y = 0; y < height; y += 1) {
|
|
105
|
+
for (let x = 0; x < width; x += 1) {
|
|
106
|
+
const index = y * width + x;
|
|
107
|
+
const current = sample[index] ?? 0;
|
|
108
|
+
const left = x > 0 ? sample[index - 1] ?? current : current;
|
|
109
|
+
const up = y > 0 ? sample[index - width] ?? current : current;
|
|
110
|
+
const right = x < width - 1 ? sample[index + 1] ?? current : current;
|
|
111
|
+
const down = y < height - 1 ? sample[index + width] ?? current : current;
|
|
112
|
+
const energy = Math.abs(current - left) +
|
|
113
|
+
Math.abs(current - right) +
|
|
114
|
+
Math.abs(current - up) +
|
|
115
|
+
Math.abs(current - down);
|
|
116
|
+
rowActivity[y] += energy;
|
|
117
|
+
colActivity[x] += energy;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const smoothedRows = smoothSeries(rowActivity.map((value) => value / width), 9);
|
|
121
|
+
const smoothedCols = smoothSeries(colActivity.map((value) => value / height), 7);
|
|
122
|
+
const rowBounds = findActiveRange(smoothedRows, Math.round(height * 0.18));
|
|
123
|
+
const colBounds = findActiveRange(smoothedCols, Math.round(width * 0.16));
|
|
124
|
+
if (!rowBounds || !colBounds) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const topMargin = rowBounds.start / height;
|
|
128
|
+
const bottomMargin = (height - rowBounds.end - 1) / height;
|
|
129
|
+
const leftMargin = colBounds.start / width;
|
|
130
|
+
const rightMargin = (width - colBounds.end - 1) / width;
|
|
131
|
+
const croppedWidth = colBounds.end - colBounds.start + 1;
|
|
132
|
+
const croppedHeight = rowBounds.end - rowBounds.start + 1;
|
|
133
|
+
const croppedAreaRatio = (croppedWidth * croppedHeight) / (width * height);
|
|
134
|
+
const significantMargins = topMargin > 0.11 || bottomMargin > 0.11 || leftMargin > 0.08 || rightMargin > 0.08;
|
|
135
|
+
if (!significantMargins && croppedAreaRatio > 0.9) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
left: colBounds.start,
|
|
140
|
+
top: rowBounds.start,
|
|
141
|
+
width: croppedWidth,
|
|
142
|
+
height: croppedHeight
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async function detectFocusPoint(image, sourceWidth, sourceHeight) {
|
|
146
|
+
const sampleWidth = 120;
|
|
147
|
+
const sampleHeight = Math.max(1, Math.round((sourceHeight / Math.max(sourceWidth, 1)) * sampleWidth));
|
|
148
|
+
const sample = await image
|
|
149
|
+
.clone()
|
|
150
|
+
.resize(sampleWidth, sampleHeight, { fit: "fill" })
|
|
151
|
+
.grayscale()
|
|
152
|
+
.raw()
|
|
153
|
+
.toBuffer();
|
|
154
|
+
let weightedX = 0;
|
|
155
|
+
let weightedY = 0;
|
|
156
|
+
let totalWeight = 0;
|
|
157
|
+
for (let y = 0; y < sampleHeight; y += 1) {
|
|
158
|
+
for (let x = 0; x < sampleWidth; x += 1) {
|
|
159
|
+
const index = y * sampleWidth + x;
|
|
160
|
+
const current = sample[index] ?? 0;
|
|
161
|
+
const left = x > 0 ? sample[index - 1] ?? current : current;
|
|
162
|
+
const up = y > 0 ? sample[index - sampleWidth] ?? current : current;
|
|
163
|
+
const right = x < sampleWidth - 1 ? sample[index + 1] ?? current : current;
|
|
164
|
+
const down = y < sampleHeight - 1 ? sample[index + sampleWidth] ?? current : current;
|
|
165
|
+
const energy = Math.abs(current - left) +
|
|
166
|
+
Math.abs(current - right) +
|
|
167
|
+
Math.abs(current - up) +
|
|
168
|
+
Math.abs(current - down);
|
|
169
|
+
const centerBiasX = 1 - Math.abs((x + 0.5) / sampleWidth - 0.5) * 0.45;
|
|
170
|
+
const centerBiasY = 1 - Math.abs((y + 0.5) / sampleHeight - 0.5) * 0.35;
|
|
171
|
+
const weight = Math.max(energy, 1) * centerBiasX * centerBiasY;
|
|
172
|
+
weightedX += (x + 0.5) * weight;
|
|
173
|
+
weightedY += (y + 0.5) * weight;
|
|
174
|
+
totalWeight += weight;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (totalWeight <= 0) {
|
|
178
|
+
return { x: sourceWidth / 2, y: sourceHeight / 2 };
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
x: (weightedX / totalWeight / sampleWidth) * sourceWidth,
|
|
182
|
+
y: (weightedY / totalWeight / sampleHeight) * sourceHeight
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function findActiveRange(values, minSpan) {
|
|
186
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
|
|
187
|
+
const max = values.reduce((best, value) => Math.max(best, value), 0);
|
|
188
|
+
if (max <= 0) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const threshold = mean + (max - mean) * 0.18;
|
|
192
|
+
let start = 0;
|
|
193
|
+
while (start < values.length && values[start] < threshold) {
|
|
194
|
+
start += 1;
|
|
195
|
+
}
|
|
196
|
+
let end = values.length - 1;
|
|
197
|
+
while (end >= 0 && values[end] < threshold) {
|
|
198
|
+
end -= 1;
|
|
199
|
+
}
|
|
200
|
+
if (start >= end) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const desiredSpan = Math.max(minSpan, end - start + 1);
|
|
204
|
+
const extra = desiredSpan - (end - start + 1);
|
|
205
|
+
const expandStart = Math.floor(extra / 2);
|
|
206
|
+
const expandEnd = extra - expandStart;
|
|
207
|
+
start = Math.max(0, start - expandStart - 4);
|
|
208
|
+
end = Math.min(values.length - 1, end + expandEnd + 4);
|
|
209
|
+
return { start, end };
|
|
210
|
+
}
|
|
211
|
+
function smoothSeries(values, radius) {
|
|
212
|
+
return values.map((_, index) => {
|
|
213
|
+
let total = 0;
|
|
214
|
+
let count = 0;
|
|
215
|
+
for (let offset = -radius; offset <= radius; offset += 1) {
|
|
216
|
+
const value = values[index + offset];
|
|
217
|
+
if (value === undefined) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
total += value;
|
|
221
|
+
count += 1;
|
|
222
|
+
}
|
|
223
|
+
return total / Math.max(count, 1);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function clamp(value, min, max) {
|
|
227
|
+
return Math.min(Math.max(value, min), max);
|
|
228
|
+
}
|
|
229
|
+
async function ensureExactPixelSize(input, target) {
|
|
230
|
+
const metadata = await sharp(input).metadata();
|
|
231
|
+
if (metadata.width === target.width && metadata.height === target.height) {
|
|
232
|
+
return input;
|
|
233
|
+
}
|
|
234
|
+
const exactCrop = await cropToPortraitAspect(sharp(input), target.width / target.height);
|
|
235
|
+
return exactCrop
|
|
236
|
+
.resize(target.width, target.height, {
|
|
237
|
+
fit: "cover",
|
|
238
|
+
position: sharp.strategy.attention
|
|
239
|
+
})
|
|
240
|
+
.png()
|
|
241
|
+
.toBuffer();
|
|
242
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AbsoluteFill, Composition, Img, Sequence, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
export const RemotionRoot = () => {
|
|
4
|
+
return (_jsx(Composition, { id: "template-0000", component: TemplateVideo, width: 1080, height: 1920, fps: 30, durationInFrames: 120, defaultProps: { slides: [] }, calculateMetadata: ({ props }) => ({
|
|
5
|
+
width: 1080,
|
|
6
|
+
height: 1920,
|
|
7
|
+
durationInFrames: Math.max(1, sumSlideFrames(props.slides, 30))
|
|
8
|
+
}) }));
|
|
9
|
+
};
|
|
10
|
+
const TemplateVideo = ({ slides }) => {
|
|
11
|
+
const { fps } = useVideoConfig();
|
|
12
|
+
let currentFrame = 0;
|
|
13
|
+
return (_jsx(AbsoluteFill, { style: { backgroundColor: "#120f0b" }, children: slides.map((slide, index) => {
|
|
14
|
+
const durationInFrames = msToFrames(slide.durationMs, fps);
|
|
15
|
+
const sequence = (_jsx(Sequence, { from: currentFrame, durationInFrames: durationInFrames, children: _jsx(SlideFrame, { slide: slide }) }, `${slide.imageUrl}-${index}`));
|
|
16
|
+
currentFrame += durationInFrames;
|
|
17
|
+
return sequence;
|
|
18
|
+
}) }));
|
|
19
|
+
};
|
|
20
|
+
const SlideFrame = ({ slide }) => {
|
|
21
|
+
const frame = useCurrentFrame();
|
|
22
|
+
const { fps } = useVideoConfig();
|
|
23
|
+
const entrance = spring({ fps, frame, config: { damping: 200, stiffness: 140 } });
|
|
24
|
+
const opacity = interpolate(entrance, [0, 1], [0.35, 1]);
|
|
25
|
+
const lift = interpolate(entrance, [0, 1], [22, 0]);
|
|
26
|
+
return (_jsxs(AbsoluteFill, { children: [_jsx(AbsoluteFill, { style: { opacity, transform: `translateY(${lift}px)` }, children: _jsx(Img, { src: slide.imageUrl, style: { width: "100%", height: "100%", objectFit: "fill" } }) }), _jsx(AbsoluteFill, { style: { background: "linear-gradient(180deg, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.06) 36%, rgba(0,0,0,0.34) 100%)" } })] }));
|
|
27
|
+
};
|
|
28
|
+
function msToFrames(durationMs, fps) {
|
|
29
|
+
return Math.max(1, Math.round((durationMs / 1000) * fps));
|
|
30
|
+
}
|
|
31
|
+
function sumSlideFrames(slides, fps) {
|
|
32
|
+
return slides.reduce((total, slide) => total + msToFrames(slide.durationMs, fps), 0);
|
|
33
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const TEMPLATE_FONT_OPTIONS = [
|
|
2
|
+
{
|
|
3
|
+
id: "source_code_pro",
|
|
4
|
+
label: "Source Code Pro",
|
|
5
|
+
family: "Source Code Pro",
|
|
6
|
+
assetFile: "SourceCodePro[wght].ttf"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
id: "montserrat",
|
|
10
|
+
label: "Montserrat",
|
|
11
|
+
family: "Montserrat",
|
|
12
|
+
assetFile: "Montserrat[wght].ttf"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "yesteryear",
|
|
16
|
+
label: "Yesteryear",
|
|
17
|
+
family: "Yesteryear",
|
|
18
|
+
assetFile: "Yesteryear-Regular.ttf"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "dm_serif_display",
|
|
22
|
+
label: "DM Serif Display",
|
|
23
|
+
family: "DM Serif Display",
|
|
24
|
+
assetFile: "DMSerifDisplay-Regular.ttf"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "abel",
|
|
28
|
+
label: "Abel",
|
|
29
|
+
family: "Abel",
|
|
30
|
+
assetFile: "Abel-Regular.ttf"
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
export const TEMPLATE_TEXT_BACKGROUND_COLOR_OPTIONS = [
|
|
34
|
+
{ id: "black", label: "Black", hex: "#000000" },
|
|
35
|
+
{ id: "red", label: "Red", hex: "#EA403F" },
|
|
36
|
+
{ id: "orange", label: "Orange", hex: "#FF933D" },
|
|
37
|
+
{ id: "yellow", label: "Yellow", hex: "#F2CD46" },
|
|
38
|
+
{ id: "lime_green", label: "Lime Green", hex: "#78C25E" },
|
|
39
|
+
{ id: "teal", label: "Teal", hex: "#77C8A6" },
|
|
40
|
+
{ id: "light_blue", label: "Light Blue", hex: "#3496F0" },
|
|
41
|
+
{ id: "dark_blue", label: "Dark Blue", hex: "#3496F0" },
|
|
42
|
+
{ id: "violet", label: "Violet", hex: "#5756D4" },
|
|
43
|
+
{ id: "pink", label: "Pink", hex: "#F7D7E9" },
|
|
44
|
+
{ id: "brown", label: "Brown", hex: "#A3895B" },
|
|
45
|
+
{ id: "dark_green", label: "Dark Green", hex: "#32523B" },
|
|
46
|
+
{ id: "blue_gray", label: "Blue Gray", hex: "#2F688C" },
|
|
47
|
+
{ id: "light_gray", label: "Light Gray", hex: "#92979E" },
|
|
48
|
+
{ id: "dark_gray", label: "Dark Gray", hex: "#333333" }
|
|
49
|
+
];
|
|
50
|
+
export const TEMPLATE_FONT_IDS = TEMPLATE_FONT_OPTIONS.map((option) => option.id);
|
|
51
|
+
export const TEMPLATE_TEXT_BACKGROUND_COLOR_IDS = TEMPLATE_TEXT_BACKGROUND_COLOR_OPTIONS.map((option) => option.id);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Generated by `vidfarm analyze-viral-dna` and `vidfarm analyze-visual-dna`.
|
|
2
|
+
// Keep source notes in `research/source_notes.md` and reference media in `research/preview/`.
|
|
3
|
+
export const templateLinkToOriginal = "";
|
|
4
|
+
export const templateSourceNotesPath = "research/source_notes.md";
|
|
5
|
+
export const templatePreviewMediaRelativePaths = [];
|
|
6
|
+
export const templateViralDna = "Run `vidfarm analyze-viral-dna --template-dir .` after adding source notes and preview media.";
|
|
7
|
+
export const templateVisualDna = "Run `vidfarm analyze-visual-dna --template-dir .` after adding source notes and preview media.";
|
|
8
|
+
export const templateViralDnaAnalysis = null;
|
|
9
|
+
export const templateVisualDnaAnalysis = null;
|