@mevdragon/vidfarm-devcli 0.2.1 → 0.2.3
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 +6 -39
- package/GETTING_STARTED.developers.md +87 -0
- package/README.md +94 -238
- package/SKILL.developer.md +430 -104
- package/dist/src/account-pages.js +1 -1
- package/dist/src/app.js +93 -5
- package/dist/src/cli.js +456 -8
- package/dist/src/config.js +3 -2
- package/dist/src/context.js +30 -11
- package/dist/src/db.js +2 -57
- package/dist/src/dev-app.js +0 -1
- package/dist/src/index.js +4 -2
- package/dist/src/lib/template-paths.js +21 -0
- package/dist/src/runtime.js +3 -1
- package/dist/src/services/auth.js +4 -4
- package/dist/src/services/job-logs.js +186 -0
- package/dist/src/services/jobs.js +3 -2
- package/dist/src/services/providers.js +14 -6
- package/dist/src/services/storage.js +85 -2
- package/dist/src/services/template-sources.js +29 -3
- package/dist/templates/template_0000/src/lib/images.js +46 -86
- package/dist/templates/template_0000/src/template.js +277 -53
- package/package.json +5 -6
- package/templates/template_0000/README.md +8 -52
- package/templates/template_0000/SKILL.md +35 -3
- package/templates/template_0000/package.json +3 -6
- package/templates/template_0000/src/lib/images.js +46 -86
- package/templates/template_0000/src/lib/images.ts +55 -98
- package/templates/template_0000/src/template-dna.js +9 -0
- package/templates/template_0000/src/template.js +523 -199
- package/templates/template_0000/src/template.ts +356 -61
- package/templates/template_0000/template.config.json +7 -12
- package/AWS_REMOTION_HANDOFF.md +0 -311
- package/PLATFORM_SPEC.md +0 -1039
- package/SKILL.director.md +0 -599
- package/dist/infra/cdk/bin/vidfarm-prod.js +0 -59
- package/dist/infra/cdk/lib/vidfarm-prod-stack.js +0 -212
- package/templates/template_0000/package-lock.json +0 -5505
- package/templates/template_0000/scripts/create-site.mjs +0 -27
- package/templates/template_0000/scripts/render-cloud.mjs +0 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Developer CLI for running the Vidfarm local template platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
"vidfarm-devcli": "dist/src/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"dist",
|
|
11
|
+
"dist/src",
|
|
12
|
+
"dist/templates/template_0000",
|
|
12
13
|
"templates/template_0000",
|
|
13
14
|
"README.md",
|
|
14
|
-
"SKILL.director.md",
|
|
15
15
|
"SKILL.developer.md",
|
|
16
|
-
"
|
|
17
|
-
"AWS_REMOTION_HANDOFF.md",
|
|
16
|
+
"GETTING_STARTED.developers.md",
|
|
18
17
|
".env.example"
|
|
19
18
|
],
|
|
20
19
|
"publishConfig": {
|
|
@@ -22,7 +21,7 @@
|
|
|
22
21
|
},
|
|
23
22
|
"repository": {
|
|
24
23
|
"type": "git",
|
|
25
|
-
"url": "git+https://github.com/OfficeXApp/vidfarm
|
|
24
|
+
"url": "git+https://github.com/OfficeXApp/vidfarm.git"
|
|
26
25
|
},
|
|
27
26
|
"engines": {
|
|
28
27
|
"node": ">=22.0.0"
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Standalone GitHub-distributable package for the Vidfarm `template_0000` starter template.
|
|
4
4
|
|
|
5
|
-
This package exists
|
|
5
|
+
This package exists to give third-party developers a concrete example of the Vidfarm template contract.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Developers should treat this repo as an authoring and review unit.
|
|
8
|
+
The canonical template implementation lives in `src/template.ts`; the build emits `src/template.js` for runtime loading.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
When a template is ready, it is handed off for Vidfarm admin review and approval before it becomes available on the hosted platform.
|
|
11
11
|
|
|
12
12
|
Every new template should keep its source-format research checked in:
|
|
13
13
|
|
|
@@ -15,33 +15,21 @@ Every new template should keep its source-format research checked in:
|
|
|
15
15
|
- `research/preview/` for the screenshots or source video the DNA analyzers inspect
|
|
16
16
|
- `src/template-dna.ts` for the generated viral and visual DNA that feed the published template metadata
|
|
17
17
|
|
|
18
|
-
The production release flow is:
|
|
19
|
-
|
|
20
|
-
1. developer pushes template code to GitHub
|
|
21
|
-
2. admin reviews the repo and selects a commit from `production`
|
|
22
|
-
3. admin publishes the approved Remotion site bundle to shared AWS
|
|
23
|
-
4. admin imports and activates the approved commit in Vidfarm
|
|
24
|
-
5. admin rebuilds and redeploys the production Docker image
|
|
25
|
-
|
|
26
|
-
Template authors do not publish directly to shared Remotion Lambda and do not directly promote templates into production Docker.
|
|
27
|
-
|
|
28
18
|
Production integration settings are checked into `template.config.json`. AI agents and developers should treat that file as the source of truth for:
|
|
29
19
|
|
|
30
20
|
- project identity
|
|
31
21
|
- `template_id` as the self-issued UUIDv4 platform identifier
|
|
32
22
|
- `slug_id` as the human-readable stable slug
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
- Remotion region/function/bucket/site/composition settings
|
|
36
|
-
- release control expectations for admin promotion
|
|
23
|
+
- the template module path, which should be the TypeScript source entrypoint `src/template.ts`
|
|
24
|
+
- the local Remotion composition settings used by the starter template
|
|
37
25
|
|
|
38
26
|
## Included
|
|
39
27
|
|
|
40
28
|
- `SKILL.md` for customer AI-agent usage
|
|
41
29
|
- `src/template.ts` for the Vidfarm platform contract
|
|
42
|
-
- `src/remotion/*` for local
|
|
30
|
+
- `src/remotion/*` for local video rendering
|
|
43
31
|
- `src/lib/images.ts` for non-cropping portrait normalization
|
|
44
|
-
- `composition.json` sample props for
|
|
32
|
+
- `composition.json` sample props for local render tests
|
|
45
33
|
|
|
46
34
|
## Template behavior
|
|
47
35
|
|
|
@@ -87,35 +75,3 @@ vidfarm analyze-visual-dna --template-dir .
|
|
|
87
75
|
```
|
|
88
76
|
|
|
89
77
|
If you scaffold a template with `vidfarm generate-template` and provide `--source-preview-dir`, the CLI will run both analyzers automatically unless `--skip-dna-analysis` is set.
|
|
90
|
-
|
|
91
|
-
## Shared Remotion AWS
|
|
92
|
-
|
|
93
|
-
Expected shared infra:
|
|
94
|
-
|
|
95
|
-
- region: stored in `template.config.json`
|
|
96
|
-
- function: stored in `template.config.json`
|
|
97
|
-
- bucket: stored in `template.config.json`
|
|
98
|
-
|
|
99
|
-
The only runtime secrets expected from the execution environment are AWS credentials. The production Remotion topology is intentionally checked into `template.config.json`, not spread across `.env` values.
|
|
100
|
-
|
|
101
|
-
These shared-AWS operations are release-admin tasks. Template developers should not run them against the shared production AWS account.
|
|
102
|
-
|
|
103
|
-
Release admin create-site:
|
|
104
|
-
|
|
105
|
-
```bash
|
|
106
|
-
npm run admin:create-site
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
Release admin cloud render smoke test:
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
npm run admin:render:cloud
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Release admin cloud render from a real stage-1 manifest:
|
|
116
|
-
|
|
117
|
-
```bash
|
|
118
|
-
npm run admin:render:cloud -- --manifest /abs/path/to/template-0000.json
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
Use the manifest mode when you want the cloud render verification to include the actual composited slide frames with text overlays, not just the static sample props.
|
|
@@ -5,8 +5,8 @@ Use this template when the customer wants a fast vertical slideshow for TikTok-s
|
|
|
5
5
|
Operational rule:
|
|
6
6
|
|
|
7
7
|
- template developers author and test this template locally
|
|
8
|
-
-
|
|
9
|
-
-
|
|
8
|
+
- the hosted platform later runs the approved template behind the standard Vidfarm API wrapper
|
|
9
|
+
- a Vidfarm admin reviews and approves the template before it is made available in production
|
|
10
10
|
|
|
11
11
|
Inputs:
|
|
12
12
|
|
|
@@ -18,12 +18,44 @@ Behavior:
|
|
|
18
18
|
|
|
19
19
|
- `create_slideshow` generates exact 9:16 images, preserves the exact overlay text, and outputs finished slide frames plus a manifest
|
|
20
20
|
- `render_video` turns explicit finished slide frames into an auto-advancing vertical MP4
|
|
21
|
+
- when chaining operations, pass `result.renderVideoInput` from `create_slideshow` into `render_video` without re-encoding or HTML-escaping the slide URLs
|
|
21
22
|
- reserves space for TikTok UI chrome at the top, right edge, and bottom caption area
|
|
22
23
|
- keeps subjects away from edge crops and avoids cutting off faces, pets, hands, and products
|
|
23
24
|
- prefers centered native-caption placement using image analysis first, with local fallback scoring
|
|
24
25
|
- never uses a full paragraph wash behind captions; when enabled, text backgrounds render as tight chips around the words
|
|
25
26
|
- restricts caption fonts and text background colors to the checked-in starter option lists in `src/style-options.ts`
|
|
26
27
|
|
|
28
|
+
AI dependency contract for agents:
|
|
29
|
+
|
|
30
|
+
- the current Vidfarm template standard does not have a first-class `dependencies` field, so this section is the authoritative dependency declaration for this template
|
|
31
|
+
- `defaultProvider`, `textModel`, and `imageModel` are config values, not a validated provider-model matrix
|
|
32
|
+
- agents should keep provider and model choices inside the combinations listed below unless they are intentionally extending runtime support
|
|
33
|
+
|
|
34
|
+
Documented provider-model combinations for this template:
|
|
35
|
+
|
|
36
|
+
- `openai`
|
|
37
|
+
models:
|
|
38
|
+
`image`: `gpt-image-1`, `gpt-image-2`
|
|
39
|
+
`text` and `layout_analysis`: `gpt-5.4`
|
|
40
|
+
planned but not wired here: `sora-2`
|
|
41
|
+
- `gemini`
|
|
42
|
+
models:
|
|
43
|
+
`image`: `gemini-3.1-flash-image-preview`, `gemini-2.5-flash-image`
|
|
44
|
+
`text` and `layout_analysis`: `gemini-3.1-flash-lite`, `gemini-2.5-flash-lite`
|
|
45
|
+
planned but not wired here: `veo-3.0-generate-001`
|
|
46
|
+
- `openrouter`
|
|
47
|
+
models:
|
|
48
|
+
`text` and `layout_analysis`: `qwen/qwen3.6-flash`
|
|
49
|
+
aspirational image models: `bytedance/seedance-2.0`, `bytedance-seed/seedream-4.5`
|
|
50
|
+
|
|
51
|
+
Runtime caveats:
|
|
52
|
+
|
|
53
|
+
- `openrouter` text and layout-analysis calls work through the OpenRouter chat-completions path
|
|
54
|
+
- `openrouter` image generation is not a first-class adapter path in the current runtime, so do not assume `seedance-2.0` or `seedream-4.5` will work end to end without provider-runtime changes
|
|
55
|
+
- OpenAI image generation in the current runtime does not support `image_prompt_attachments`; Gemini is the safest path when reference-image attachments matter
|
|
56
|
+
- OpenAI image generation does not expose an exact native `9:16` output size in the current API. For `template_0000`, use portrait output plus explicit `9:16` framing instructions, then preserve composition with contain-plus-blur normalization instead of crop-zooming.
|
|
57
|
+
- this template does not expose a video-generation operation, so `sora-2` and `veo-3.0-generate-001` are documented here for future dependency planning only
|
|
58
|
+
|
|
27
59
|
Allowed caption fonts:
|
|
28
60
|
|
|
29
61
|
- `source_code_pro`
|
|
@@ -51,7 +83,7 @@ Allowed text background colors:
|
|
|
51
83
|
- `light_gray`
|
|
52
84
|
- `dark_gray`
|
|
53
85
|
|
|
54
|
-
|
|
86
|
+
Typical routes for this template:
|
|
55
87
|
|
|
56
88
|
- `GET /templates/template_0000`
|
|
57
89
|
- `GET /templates/template_0000/skill`
|
|
@@ -2,19 +2,16 @@
|
|
|
2
2
|
"name": "vidfarm_template_0000",
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "Standalone Vidfarm template_0000 project for
|
|
5
|
+
"description": "Standalone Vidfarm template_0000 starter project for local template authoring.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
9
9
|
"build": "tsc -p tsconfig.json",
|
|
10
|
-
"studio": "remotion studio src/remotion/index.tsx"
|
|
11
|
-
"admin:create-site": "node ./scripts/create-site.mjs",
|
|
12
|
-
"admin:render:cloud": "node ./scripts/render-cloud.mjs"
|
|
10
|
+
"studio": "remotion studio src/remotion/index.tsx"
|
|
13
11
|
},
|
|
14
12
|
"dependencies": {
|
|
15
13
|
"@remotion/bundler": "4.0.355",
|
|
16
14
|
"@remotion/cli": "4.0.355",
|
|
17
|
-
"@remotion/lambda": "4.0.355",
|
|
18
15
|
"@remotion/renderer": "4.0.355",
|
|
19
16
|
"react": "^18.3.1",
|
|
20
17
|
"react-dom": "^18.3.1",
|
|
@@ -22,7 +19,7 @@
|
|
|
22
19
|
"sharp": "^0.34.2"
|
|
23
20
|
},
|
|
24
21
|
"devDependencies": {
|
|
25
|
-
"@mevdragon/vidfarm-devcli": "^0.2.
|
|
22
|
+
"@mevdragon/vidfarm-devcli": "^0.2.2",
|
|
26
23
|
"@types/node": "^24.0.1",
|
|
27
24
|
"@types/react": "^18.3.23",
|
|
28
25
|
"@types/react-dom": "^18.3.7",
|
|
@@ -4,25 +4,60 @@ export async function normalizeToPortraitFrame(input, target = { width: 1080, he
|
|
|
4
4
|
const oriented = sharp(buffer, { density: 144 }).rotate();
|
|
5
5
|
const trimmed = await trimFlatBorders(oriented);
|
|
6
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
7
|
const shouldPreserveFraming = await isCloseToAspect(trimmed, targetAspect, 0.025);
|
|
10
8
|
const cropped = shouldPreserveFraming ? trimmed : await cropToActiveImageRegion(trimmed);
|
|
11
|
-
const
|
|
12
|
-
|
|
9
|
+
const normalized = await resizeWithBlurredContain(cropped, target, targetAspect);
|
|
10
|
+
return {
|
|
11
|
+
bytes: normalized,
|
|
12
|
+
contentType: "image/png",
|
|
13
|
+
width: target.width,
|
|
14
|
+
height: target.height
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function resizeWithBlurredContain(image, target, targetAspect) {
|
|
18
|
+
const metadata = await image.metadata();
|
|
19
|
+
const width = metadata.width ?? 0;
|
|
20
|
+
const height = metadata.height ?? 0;
|
|
21
|
+
if (!width || !height) {
|
|
22
|
+
return image
|
|
23
|
+
.resize(target.width, target.height, {
|
|
24
|
+
fit: "cover",
|
|
25
|
+
position: sharp.strategy.attention
|
|
26
|
+
})
|
|
27
|
+
.png()
|
|
28
|
+
.toBuffer();
|
|
29
|
+
}
|
|
30
|
+
const aspect = width / height;
|
|
31
|
+
if (Math.abs(aspect - targetAspect) <= 0.015) {
|
|
32
|
+
return image
|
|
33
|
+
.resize(target.width, target.height, {
|
|
34
|
+
fit: "fill"
|
|
35
|
+
})
|
|
36
|
+
.png()
|
|
37
|
+
.toBuffer();
|
|
38
|
+
}
|
|
39
|
+
const foreground = await image
|
|
40
|
+
.clone()
|
|
41
|
+
.resize(target.width, target.height, {
|
|
42
|
+
fit: "contain",
|
|
43
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
44
|
+
})
|
|
45
|
+
.png()
|
|
46
|
+
.toBuffer();
|
|
47
|
+
const background = await image
|
|
48
|
+
.clone()
|
|
13
49
|
.resize(target.width, target.height, {
|
|
14
50
|
fit: "cover",
|
|
15
51
|
position: sharp.strategy.attention
|
|
16
52
|
})
|
|
53
|
+
.blur(20)
|
|
54
|
+
.modulate({ brightness: 0.92, saturation: 0.9 })
|
|
55
|
+
.png()
|
|
56
|
+
.toBuffer();
|
|
57
|
+
return sharp(background)
|
|
58
|
+
.composite([{ input: foreground }])
|
|
17
59
|
.png()
|
|
18
60
|
.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
61
|
}
|
|
27
62
|
async function isCloseToAspect(image, targetAspect, tolerance) {
|
|
28
63
|
const metadata = await image.metadata();
|
|
@@ -77,27 +112,6 @@ async function cropToActiveImageRegion(image) {
|
|
|
77
112
|
const height = Math.min(sourceHeight - top, Math.max(1, Math.ceil((bounds.height / sampleHeight) * sourceHeight)));
|
|
78
113
|
return image.extract({ left, top, width, height });
|
|
79
114
|
}
|
|
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
115
|
function detectActiveBounds(sample, width, height) {
|
|
102
116
|
const rowActivity = new Array(height).fill(0);
|
|
103
117
|
const colActivity = new Array(width).fill(0);
|
|
@@ -142,46 +156,6 @@ function detectActiveBounds(sample, width, height) {
|
|
|
142
156
|
height: croppedHeight
|
|
143
157
|
};
|
|
144
158
|
}
|
|
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
159
|
function findActiveRange(values, minSpan) {
|
|
186
160
|
const mean = values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
|
|
187
161
|
const max = values.reduce((best, value) => Math.max(best, value), 0);
|
|
@@ -226,17 +200,3 @@ function smoothSeries(values, radius) {
|
|
|
226
200
|
function clamp(value, min, max) {
|
|
227
201
|
return Math.min(Math.max(value, min), max);
|
|
228
202
|
}
|
|
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
|
-
}
|
|
@@ -8,26 +8,70 @@ export async function normalizeToPortraitFrame(
|
|
|
8
8
|
const oriented = sharp(buffer, { density: 144 }).rotate();
|
|
9
9
|
const trimmed = await trimFlatBorders(oriented);
|
|
10
10
|
const targetAspect = target.width / target.height;
|
|
11
|
-
// If the model already returned a near-native vertical frame, avoid the
|
|
12
|
-
// activity crop pass because it can misread low-detail edges as padding.
|
|
13
11
|
const shouldPreserveFraming = await isCloseToAspect(trimmed, targetAspect, 0.025);
|
|
14
12
|
const cropped = shouldPreserveFraming ? trimmed : await cropToActiveImageRegion(trimmed);
|
|
15
|
-
const
|
|
16
|
-
|
|
13
|
+
const normalized = await resizeWithBlurredContain(cropped, target, targetAspect);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
bytes: normalized,
|
|
17
|
+
contentType: "image/png" as const,
|
|
18
|
+
width: target.width,
|
|
19
|
+
height: target.height
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resizeWithBlurredContain(
|
|
24
|
+
image: sharp.Sharp,
|
|
25
|
+
target: { width: number; height: number },
|
|
26
|
+
targetAspect: number
|
|
27
|
+
) {
|
|
28
|
+
const metadata = await image.metadata();
|
|
29
|
+
const width = metadata.width ?? 0;
|
|
30
|
+
const height = metadata.height ?? 0;
|
|
31
|
+
if (!width || !height) {
|
|
32
|
+
return image
|
|
33
|
+
.resize(target.width, target.height, {
|
|
34
|
+
fit: "cover",
|
|
35
|
+
position: sharp.strategy.attention
|
|
36
|
+
})
|
|
37
|
+
.png()
|
|
38
|
+
.toBuffer();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const aspect = width / height;
|
|
42
|
+
if (Math.abs(aspect - targetAspect) <= 0.015) {
|
|
43
|
+
return image
|
|
44
|
+
.resize(target.width, target.height, {
|
|
45
|
+
fit: "fill"
|
|
46
|
+
})
|
|
47
|
+
.png()
|
|
48
|
+
.toBuffer();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const foreground = await image
|
|
52
|
+
.clone()
|
|
53
|
+
.resize(target.width, target.height, {
|
|
54
|
+
fit: "contain",
|
|
55
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
56
|
+
})
|
|
57
|
+
.png()
|
|
58
|
+
.toBuffer();
|
|
59
|
+
|
|
60
|
+
const background = await image
|
|
61
|
+
.clone()
|
|
17
62
|
.resize(target.width, target.height, {
|
|
18
63
|
fit: "cover",
|
|
19
64
|
position: sharp.strategy.attention
|
|
20
65
|
})
|
|
66
|
+
.blur(20)
|
|
67
|
+
.modulate({ brightness: 0.92, saturation: 0.9 })
|
|
21
68
|
.png()
|
|
22
69
|
.toBuffer();
|
|
23
|
-
const exact = await ensureExactPixelSize(output, target);
|
|
24
70
|
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
height: target.height
|
|
30
|
-
};
|
|
71
|
+
return sharp(background)
|
|
72
|
+
.composite([{ input: foreground }])
|
|
73
|
+
.png()
|
|
74
|
+
.toBuffer();
|
|
31
75
|
}
|
|
32
76
|
|
|
33
77
|
async function isCloseToAspect(image: sharp.Sharp, targetAspect: number, tolerance: number) {
|
|
@@ -93,31 +137,6 @@ async function cropToActiveImageRegion(image: sharp.Sharp) {
|
|
|
93
137
|
return image.extract({ left, top, width, height });
|
|
94
138
|
}
|
|
95
139
|
|
|
96
|
-
async function cropToPortraitAspect(image: sharp.Sharp, targetAspect: number) {
|
|
97
|
-
const metadata = await image.metadata();
|
|
98
|
-
const sourceWidth = metadata.width ?? 0;
|
|
99
|
-
const sourceHeight = metadata.height ?? 0;
|
|
100
|
-
if (!sourceWidth || !sourceHeight) {
|
|
101
|
-
return image;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const sourceAspect = sourceWidth / sourceHeight;
|
|
105
|
-
if (Math.abs(sourceAspect - targetAspect) < 0.015) {
|
|
106
|
-
return image;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const focus = await detectFocusPoint(image, sourceWidth, sourceHeight);
|
|
110
|
-
if (sourceAspect > targetAspect) {
|
|
111
|
-
const cropWidth = Math.max(1, Math.min(sourceWidth, Math.round(sourceHeight * targetAspect)));
|
|
112
|
-
const left = clamp(Math.round(focus.x - cropWidth / 2), 0, sourceWidth - cropWidth);
|
|
113
|
-
return image.extract({ left, top: 0, width: cropWidth, height: sourceHeight });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const cropHeight = Math.max(1, Math.min(sourceHeight, Math.round(sourceWidth / targetAspect)));
|
|
117
|
-
const top = clamp(Math.round(focus.y - cropHeight / 2), 0, sourceHeight - cropHeight);
|
|
118
|
-
return image.extract({ left: 0, top, width: sourceWidth, height: cropHeight });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
140
|
function detectActiveBounds(sample: Buffer, width: number, height: number) {
|
|
122
141
|
const rowActivity = new Array<number>(height).fill(0);
|
|
123
142
|
const colActivity = new Array<number>(width).fill(0);
|
|
@@ -170,52 +189,6 @@ function detectActiveBounds(sample: Buffer, width: number, height: number) {
|
|
|
170
189
|
};
|
|
171
190
|
}
|
|
172
191
|
|
|
173
|
-
async function detectFocusPoint(image: sharp.Sharp, sourceWidth: number, sourceHeight: number) {
|
|
174
|
-
const sampleWidth = 120;
|
|
175
|
-
const sampleHeight = Math.max(1, Math.round((sourceHeight / Math.max(sourceWidth, 1)) * sampleWidth));
|
|
176
|
-
const sample = await image
|
|
177
|
-
.clone()
|
|
178
|
-
.resize(sampleWidth, sampleHeight, { fit: "fill" })
|
|
179
|
-
.grayscale()
|
|
180
|
-
.raw()
|
|
181
|
-
.toBuffer();
|
|
182
|
-
|
|
183
|
-
let weightedX = 0;
|
|
184
|
-
let weightedY = 0;
|
|
185
|
-
let totalWeight = 0;
|
|
186
|
-
|
|
187
|
-
for (let y = 0; y < sampleHeight; y += 1) {
|
|
188
|
-
for (let x = 0; x < sampleWidth; x += 1) {
|
|
189
|
-
const index = y * sampleWidth + x;
|
|
190
|
-
const current = sample[index] ?? 0;
|
|
191
|
-
const left = x > 0 ? sample[index - 1] ?? current : current;
|
|
192
|
-
const up = y > 0 ? sample[index - sampleWidth] ?? current : current;
|
|
193
|
-
const right = x < sampleWidth - 1 ? sample[index + 1] ?? current : current;
|
|
194
|
-
const down = y < sampleHeight - 1 ? sample[index + sampleWidth] ?? current : current;
|
|
195
|
-
const energy =
|
|
196
|
-
Math.abs(current - left) +
|
|
197
|
-
Math.abs(current - right) +
|
|
198
|
-
Math.abs(current - up) +
|
|
199
|
-
Math.abs(current - down);
|
|
200
|
-
const centerBiasX = 1 - Math.abs((x + 0.5) / sampleWidth - 0.5) * 0.45;
|
|
201
|
-
const centerBiasY = 1 - Math.abs((y + 0.5) / sampleHeight - 0.5) * 0.35;
|
|
202
|
-
const weight = Math.max(energy, 1) * centerBiasX * centerBiasY;
|
|
203
|
-
weightedX += (x + 0.5) * weight;
|
|
204
|
-
weightedY += (y + 0.5) * weight;
|
|
205
|
-
totalWeight += weight;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (totalWeight <= 0) {
|
|
210
|
-
return { x: sourceWidth / 2, y: sourceHeight / 2 };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
x: (weightedX / totalWeight / sampleWidth) * sourceWidth,
|
|
215
|
-
y: (weightedY / totalWeight / sampleHeight) * sourceHeight
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
192
|
function findActiveRange(values: number[], minSpan: number) {
|
|
220
193
|
const mean = values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
|
|
221
194
|
const max = values.reduce((best, value) => Math.max(best, value), 0);
|
|
@@ -266,19 +239,3 @@ function smoothSeries(values: number[], radius: number) {
|
|
|
266
239
|
function clamp(value: number, min: number, max: number) {
|
|
267
240
|
return Math.min(Math.max(value, min), max);
|
|
268
241
|
}
|
|
269
|
-
|
|
270
|
-
async function ensureExactPixelSize(input: Buffer, target: { width: number; height: number }) {
|
|
271
|
-
const metadata = await sharp(input).metadata();
|
|
272
|
-
if (metadata.width === target.width && metadata.height === target.height) {
|
|
273
|
-
return input;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const exactCrop = await cropToPortraitAspect(sharp(input), target.width / target.height);
|
|
277
|
-
return exactCrop
|
|
278
|
-
.resize(target.width, target.height, {
|
|
279
|
-
fit: "cover",
|
|
280
|
-
position: sharp.strategy.attention
|
|
281
|
-
})
|
|
282
|
-
.png()
|
|
283
|
-
.toBuffer();
|
|
284
|
-
}
|
|
@@ -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;
|