@docyrus/animaguru 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +99 -0
- package/README.md +2 -0
- package/dist/animaguru.js +6 -3
- package/package.json +1 -1
- package/templates/video/README.md +3 -1
- package/templates/video/WORKSPACE-AGENTS.md +1 -1
- package/templates/video/scripts/export-mp4.mjs +91 -13
- package/templates/video/scripts/watermark/OFL.txt +93 -0
- package/templates/video/scripts/watermark/instrument-sans.woff2 +0 -0
- package/templates/video/scripts/watermark/logo-mark-512.png +0 -0
- package/templates/video/shared/locales/en/export.json +1 -0
- package/templates/video/src/exporter.ts +11 -1
- package/templates/video/src/watermark.ts +50 -0
package/NOTICE.md
CHANGED
|
@@ -1575,6 +1575,105 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
|
1575
1575
|
SOFTWARE.
|
|
1576
1576
|
|
|
1577
1577
|
|
|
1578
|
+
## Instrument Sans (watermark font)
|
|
1579
|
+
|
|
1580
|
+
The Latin variable-weight subset in templates/video/scripts/watermark/instrument-sans.woff2 draws the "Made with AnimaGuru" badge of `npm run export:mp4 -- --watermark`.
|
|
1581
|
+
|
|
1582
|
+
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
|
1583
|
+
|
|
1584
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
1585
|
+
This license is copied below, and is also available with a FAQ at:
|
|
1586
|
+
http://scripts.sil.org/OFL
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
-----------------------------------------------------------
|
|
1590
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
1591
|
+
-----------------------------------------------------------
|
|
1592
|
+
|
|
1593
|
+
PREAMBLE
|
|
1594
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
1595
|
+
development of collaborative font projects, to support the font creation
|
|
1596
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
1597
|
+
open framework in which fonts may be shared and improved in partnership
|
|
1598
|
+
with others.
|
|
1599
|
+
|
|
1600
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
1601
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
1602
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
1603
|
+
redistributed and/or sold with any software provided that any reserved
|
|
1604
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
1605
|
+
however, cannot be released under any other type of license. The
|
|
1606
|
+
requirement for fonts to remain under this license does not apply
|
|
1607
|
+
to any document created using the fonts or their derivatives.
|
|
1608
|
+
|
|
1609
|
+
DEFINITIONS
|
|
1610
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
1611
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
1612
|
+
include source files, build scripts and documentation.
|
|
1613
|
+
|
|
1614
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
1615
|
+
copyright statement(s).
|
|
1616
|
+
|
|
1617
|
+
"Original Version" refers to the collection of Font Software components as
|
|
1618
|
+
distributed by the Copyright Holder(s).
|
|
1619
|
+
|
|
1620
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
1621
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
1622
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
1623
|
+
new environment.
|
|
1624
|
+
|
|
1625
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
1626
|
+
writer or other person who contributed to the Font Software.
|
|
1627
|
+
|
|
1628
|
+
PERMISSION & CONDITIONS
|
|
1629
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
1630
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
1631
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
1632
|
+
Software, subject to the following conditions:
|
|
1633
|
+
|
|
1634
|
+
1) Neither the Font Software nor any of its individual components,
|
|
1635
|
+
in Original or Modified Versions, may be sold by itself.
|
|
1636
|
+
|
|
1637
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
1638
|
+
redistributed and/or sold with any software, provided that each copy
|
|
1639
|
+
contains the above copyright notice and this license. These can be
|
|
1640
|
+
included either as stand-alone text files, human-readable headers or
|
|
1641
|
+
in the appropriate machine-readable metadata fields within text or
|
|
1642
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
1643
|
+
|
|
1644
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
1645
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
1646
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
1647
|
+
presented to the users.
|
|
1648
|
+
|
|
1649
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
1650
|
+
Software shall not be used to promote, endorse or advertise any
|
|
1651
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
1652
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
1653
|
+
permission.
|
|
1654
|
+
|
|
1655
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
1656
|
+
must be distributed entirely under this license, and must not be
|
|
1657
|
+
distributed under any other license. The requirement for fonts to
|
|
1658
|
+
remain under this license does not apply to any document created
|
|
1659
|
+
using the Font Software.
|
|
1660
|
+
|
|
1661
|
+
TERMINATION
|
|
1662
|
+
This license becomes null and void if any of the above conditions are
|
|
1663
|
+
not met.
|
|
1664
|
+
|
|
1665
|
+
DISCLAIMER
|
|
1666
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
1667
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
1668
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
1669
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
1670
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
1671
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
1672
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
1673
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
1674
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
1675
|
+
|
|
1676
|
+
|
|
1578
1677
|
## Art Motion references
|
|
1579
1678
|
|
|
1580
1679
|
English adaptations of Huashu Art Motion (https://github.com/alchaincyf/huashu-art-motion). Source revision and coverage are in skills/animaguru-art-motion/references/coverage.json.
|
package/README.md
CHANGED
|
@@ -47,6 +47,8 @@ CLI 0.24.0 adds eight documentary & broadcast styles, cut from real archive foot
|
|
|
47
47
|
|
|
48
48
|
CLI 0.25.0 adds seven contemporary motion-design styles: Aurora & Glass, Liquid Motion, Bauhaus Geometry, Synthwave & Y2K, Sticker Explainer, Variety Captions and One-Line Art. Their signature techniques follow the MIT-licensed [mg-styles-15](https://github.com/Vincentwei1021/mg-styles-15) collection; the skills, studies and preview art are original. Each has a runnable study (`npm run example -- aurora-glass`).
|
|
49
49
|
|
|
50
|
+
CLI 0.26.0 adds `npm run export:mp4 -- --watermark`: beside the clean MP4, the export saves `SLUG-LANGUAGE-watermarked.mp4` with AnimaGuru's "Made with AnimaGuru" badge, drawn with the font, logo and layout of the web app's watermark. AnimaGuru's lead pipeline uploads it as the film's preview, so a film no longer waits for a reviewer's browser to watermark it.
|
|
51
|
+
|
|
50
52
|
CLI 0.16.2 also installs `animaguru-art-motion` for Codex and Claude Code: one skill with 36 English art-treatment references and nine explainer motion grammars, plus AnimaGuru generation, timing, camera and review guidance. Request it explicitly for a custom film treatment; its references are separate from the gallery presets. Source coverage and the upstream MIT notice are included in the skill.
|
|
51
53
|
|
|
52
54
|
CLI 0.5.2 includes a complete local PixiJS video workspace, now with 55 visual styles:
|
package/dist/animaguru.js
CHANGED
|
@@ -19761,7 +19761,7 @@ var require_ignore2 = __commonJS({
|
|
|
19761
19761
|
// cli/package.json
|
|
19762
19762
|
var package_default = {
|
|
19763
19763
|
name: "@docyrus/animaguru",
|
|
19764
|
-
version: "0.
|
|
19764
|
+
version: "0.26.0",
|
|
19765
19765
|
description: "Agent-friendly AnimaGuru CLI built with CrustJS",
|
|
19766
19766
|
type: "module",
|
|
19767
19767
|
bin: {
|
|
@@ -45307,13 +45307,16 @@ var animation_styles_default = {
|
|
|
45307
45307
|
description: "Explore the visual language of your next animation. Choose a style here, then tell your coding agent which one you like.",
|
|
45308
45308
|
available: "AVAILABLE NOW",
|
|
45309
45309
|
previewLabel: "{{name}} style preview",
|
|
45310
|
+
playLabel: "Play the {{name}} preview",
|
|
45311
|
+
previousStyle: "Previous style",
|
|
45312
|
+
nextStyle: "Next style",
|
|
45310
45313
|
previewError: "The preview could not load. Try reloading the page.",
|
|
45311
45314
|
visualLabel: "THE LOOK",
|
|
45312
45315
|
motionLabel: "THE MOVEMENT",
|
|
45313
45316
|
categoryLabel: "WORKS WITH",
|
|
45314
45317
|
copyChoice: "Copy this style choice",
|
|
45315
45318
|
copied: "Style choice copied",
|
|
45316
|
-
copyFailed: "Copy the style ID
|
|
45319
|
+
copyFailed: "Copy the style ID and send it to your agent.",
|
|
45317
45320
|
choiceMessage: "Use {{name}} ({{id}}) as the visual and animation style for my video.",
|
|
45318
45321
|
idLabel: "Style ID",
|
|
45319
45322
|
startEyebrow: "START WITH AN IDEA",
|
|
@@ -46292,7 +46295,7 @@ var animation_styles_default = {
|
|
|
46292
46295
|
searchLabel: "Search visual styles",
|
|
46293
46296
|
searchPlaceholder: "Try paper, product, typography\u2026",
|
|
46294
46297
|
filterLabel: "Filter by visual family",
|
|
46295
|
-
emptyResults: "No styles match. Try another word
|
|
46298
|
+
emptyResults: "No styles match. Try another word.",
|
|
46296
46299
|
resultsCount_one: "{{count}} style",
|
|
46297
46300
|
resultsCount_other: "{{count}} styles",
|
|
46298
46301
|
exampleLink: "Try the bundled example",
|
package/package.json
CHANGED
|
@@ -67,11 +67,13 @@ npm run export:mp4
|
|
|
67
67
|
npm run export:mp4 -- --port 5197
|
|
68
68
|
# Captions drawn into the picture, e.g. for social feeds (saved as SLUG-LANGUAGE-captioned.mp4):
|
|
69
69
|
npm run export:mp4 -- --burn-captions
|
|
70
|
+
# Also save a copy with AnimaGuru's "Made with AnimaGuru" badge (SLUG-LANGUAGE-watermarked.mp4):
|
|
71
|
+
npm run export:mp4 -- --watermark
|
|
70
72
|
```
|
|
71
73
|
|
|
72
74
|
Open the local capture URL printed by the command and keep it open until complete. The script runs a separate loopback Vite server (default port 5196), streams high-quality JPEG frames from the Pixi canvas to the bundled FFmpeg binary, and mixes the configured soundtrack. The initial starter exports with a silent AAC track. Exporting local media does not call AnimaGuru or spend credits. FFmpeg/FFprobe binaries are supplied through platform-specific npm dependencies; installation needs a supported platform and network access.
|
|
73
75
|
|
|
74
|
-
Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Captions are not drawn into the MP4 picture: the film's `captions` resource track is saved beside it as `SLUG-LANGUAGE.srt` (for YouTube and desktop players, which load a same-named file automatically) and `SLUG-LANGUAGE.vtt` (for an HTML5 `<track>`). `--burn-captions` instead draws them into the picture and writes no subtitle files, so players cannot show them twice. `"poster"` in `film.config.json` (seconds) picks the frame saved as `SLUG-LANGUAGE.jpg`, a caption-free thumbnail 1280 pixels on its longer side and under YouTube's 2 MB limit; without it, no thumbnail is saved. The live player and HTML5 export keep their CC toggle. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to ten minutes. Set duration to an exact number of frames.
|
|
76
|
+
Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Captions are not drawn into the MP4 picture: the film's `captions` resource track is saved beside it as `SLUG-LANGUAGE.srt` (for YouTube and desktop players, which load a same-named file automatically) and `SLUG-LANGUAGE.vtt` (for an HTML5 `<track>`). `--burn-captions` instead draws them into the picture and writes no subtitle files, so players cannot show them twice. `"poster"` in `film.config.json` (seconds) picks the frame saved as `SLUG-LANGUAGE.jpg`, a caption-free thumbnail 1280 pixels on its longer side and under YouTube's 2 MB limit; without it, no thumbnail is saved. The live player and HTML5 export keep their CC toggle. `--watermark` also saves `SLUG-LANGUAGE-watermarked.mp4`, the same film with AnimaGuru's "Made with AnimaGuru" badge in its bottom-right corner, drawn with the font, logo and layout AnimaGuru's web app uses; it is the preview AnimaGuru's lead pipeline plays before a lead claims the clean film. The picture is encoded again with the badge and verified like the film; the sound is copied. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to ten minutes. Set duration to an exact number of frames.
|
|
75
77
|
|
|
76
78
|
## Capture API
|
|
77
79
|
|
|
@@ -16,7 +16,7 @@ Run commands from the workspace root:
|
|
|
16
16
|
- `npm run dev`: the workspace hub and separate `/animations/NAME/` players.
|
|
17
17
|
- `npm run build -- --animation NAME`: typecheck and build that film.
|
|
18
18
|
- `npm run export:html -- --animation NAME`: portable HTML5 ZIP with local media and common branding.
|
|
19
|
-
- `npm run export:mp4 -- --animation NAME` (not for a branching story or a quiz film): verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
|
|
19
|
+
- `npm run export:mp4 -- --animation NAME` (not for a branching story or a quiz film): verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json. `--watermark` also saves `NAME-LANG-watermarked.mp4` with AnimaGuru's "Made with AnimaGuru" badge, for an AnimaGuru lead film's preview. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
|
|
20
20
|
- `npm run review -- --animation NAME --headless`: frame, text, layout (overlap, safe area, text size), seeking and soundtrack checks, one line per finding.
|
|
21
21
|
- `npm run frames -- --animation NAME --at 1.2,4.8`: labelled contact sheets in `production/review/frames/` plus layout notes for those times.
|
|
22
22
|
- `npm run audio:vo|audio:music|audio:mix -- --animation NAME`: cut and place the voiceover (`timing.json`), fit the music bed, and master the soundtrack.
|
|
@@ -23,10 +23,13 @@ const headless = takeHeadlessFlag(args);
|
|
|
23
23
|
let port = 5196;
|
|
24
24
|
// Captions become subtitle files beside the MP4 unless --burn-captions draws them into the picture, e.g. for social feeds.
|
|
25
25
|
let burnCaptions = false;
|
|
26
|
+
// --watermark also saves NAME-watermarked.mp4, the film with AnimaGuru's "Made with AnimaGuru" badge, e.g. a lead film's preview.
|
|
27
|
+
let watermark = false;
|
|
26
28
|
for (let index = 0; index < args.length; index++) {
|
|
27
29
|
if (args[index] === "--burn-captions") burnCaptions = true;
|
|
30
|
+
else if (args[index] === "--watermark") watermark = true;
|
|
28
31
|
else if (args[index] === "--port" && /^\d+$/.test(args[index + 1] ?? "")) port = Number(args[++index]);
|
|
29
|
-
else throw new Error("Usage: npm run export:mp4 -- [--animation NAME] [--headless] [--port 5196] [--burn-captions]");
|
|
32
|
+
else throw new Error("Usage: npm run export:mp4 -- [--animation NAME] [--headless] [--port 5196] [--burn-captions] [--watermark]");
|
|
30
33
|
}
|
|
31
34
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Export port must be 1024–65535.");
|
|
32
35
|
const context = await projectContext(root, selection.animation);
|
|
@@ -47,11 +50,15 @@ if (config.audio !== null) {
|
|
|
47
50
|
if (config.poster != null && (typeof config.poster !== "number" || !Number.isFinite(config.poster) || config.poster < 0 || config.poster >= duration)) throw new Error("Set poster in film.config.json to a time within the film, in seconds, or leave it out.");
|
|
48
51
|
const thumbnailScale = Math.min(1, 1280 / Math.max(width, height));
|
|
49
52
|
const thumbnail = config.poster == null ? null : { time: config.poster, width: Math.round(width * thumbnailScale), height: Math.round(height * thumbnailScale) };
|
|
53
|
+
// The capture page draws the badge with the font and logo AnimaGuru's web app uses, so they are sent to it as base64.
|
|
54
|
+
const watermarkFiles = watermark ? Object.fromEntries(await Promise.all([["font", "instrument-sans.woff2"], ["logo", "logo-mark-512.png"]]
|
|
55
|
+
.map(async ([key, file]) => [key, (await readFile(new URL(`./watermark/${file}`, import.meta.url))).toString("base64")]))) : null;
|
|
50
56
|
const outputDirectory = context.output;
|
|
51
57
|
await mkdir(outputDirectory, { recursive: true });
|
|
52
58
|
const name = `${config.slug}-${language}${burnCaptions ? "-captioned" : ""}`;
|
|
53
59
|
let destination = path.join(outputDirectory, `${name}.mp4`);
|
|
54
|
-
|
|
60
|
+
const watermarkedFiles = watermark ? ["-watermarked.mp4", "-watermarked.partial.mp4"] : [];
|
|
61
|
+
for (const candidate of [destination, ...[".partial.mp4", ".srt", ".vtt", ".jpg", ...watermarkedFiles].map((extension) => destination.replace(/\.mp4$/, extension))]) {
|
|
55
62
|
try {
|
|
56
63
|
await access(candidate);
|
|
57
64
|
destination = path.join(outputDirectory, `${name}-${randomUUID()}.mp4`);
|
|
@@ -59,6 +66,8 @@ for (const candidate of [destination, ...[".partial.mp4", ".srt", ".vtt", ".jpg"
|
|
|
59
66
|
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
60
67
|
}
|
|
61
68
|
const partial = destination.replace(/\.mp4$/, ".partial.mp4");
|
|
69
|
+
const watermarked = destination.replace(/\.mp4$/, "-watermarked.mp4");
|
|
70
|
+
const watermarkedPartial = destination.replace(/\.mp4$/, "-watermarked.partial.mp4");
|
|
62
71
|
const token = randomUUID();
|
|
63
72
|
let encoder;
|
|
64
73
|
let encoderDone;
|
|
@@ -67,6 +76,9 @@ let encoderLog = "";
|
|
|
67
76
|
let settings;
|
|
68
77
|
let subtitles = [];
|
|
69
78
|
let thumbnailImage;
|
|
79
|
+
let badge;
|
|
80
|
+
let watermarking;
|
|
81
|
+
let ownsWatermarkedPartial = false;
|
|
70
82
|
let received = 0;
|
|
71
83
|
let lastFrameHash;
|
|
72
84
|
let frameQueue = Promise.resolve();
|
|
@@ -103,10 +115,62 @@ function jpegDimensions(bytes) {
|
|
|
103
115
|
return null;
|
|
104
116
|
}
|
|
105
117
|
|
|
118
|
+
function pngDimensions(bytes) {
|
|
119
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
120
|
+
if (bytes.length < 24 || signature.some((byte, index) => bytes[index] !== byte) || bytes.toString("latin1", 12, 16) !== "IHDR") return null;
|
|
121
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Reads an encoded MP4 back and checks that it is the whole film: frames, duration, dimensions, codecs and language. */
|
|
125
|
+
async function verify(file, what) {
|
|
126
|
+
const { stdout } = await run(ffprobe.path, ["-v", "error", "-count_frames", "-show_streams", "-show_format", "-of", "json", file], { maxBuffer: 1024 * 1024 });
|
|
127
|
+
const probe = JSON.parse(stdout);
|
|
128
|
+
const video = probe.streams.find((stream) => stream.codec_type === "video");
|
|
129
|
+
const audio = probe.streams.find((stream) => stream.codec_type === "audio");
|
|
130
|
+
if (!video || video.codec_name !== "h264" || video.width !== width || video.height !== height || Number(video.nb_read_frames) !== settings.frames || video.pix_fmt !== "yuv420p" || !audio || audio.codec_name !== "aac" || audio.tags?.language !== languageTag || Math.abs(Number(probe.format.duration) - settings.duration) > 0.15) throw new Error(`The encoded ${what} did not pass duration, frame count, dimensions, language, or codec verification.`);
|
|
131
|
+
return probe;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
Lays the badge over a copy of the encoded film. The picture is encoded again at the same quality and the sound is
|
|
136
|
+
copied. The badge is converted to the film's BT.709 limited-range colours before it is laid over, so it keeps the
|
|
137
|
+
colours of AnimaGuru's own watermarked copies.
|
|
138
|
+
*/
|
|
139
|
+
async function watermarkCopy() {
|
|
140
|
+
await writeFile(watermarkedPartial, "", { flag: "wx" });
|
|
141
|
+
ownsWatermarkedPartial = true;
|
|
142
|
+
// The badge arrives on stdin as a single PNG frame, which the overlay keeps over the whole film.
|
|
143
|
+
const child = spawn(ffmpeg.path, [
|
|
144
|
+
"-hide_banner", "-loglevel", "warning", "-y", "-i", partial, "-f", "png_pipe", "-i", "pipe:0",
|
|
145
|
+
"-filter_complex", `[1:v]scale=out_color_matrix=bt709:out_range=tv,format=yuva420p[mark];[0:v][mark]overlay=x=${badge.x}:y=${badge.y}:format=yuv420,format=yuv420p[film]`,
|
|
146
|
+
"-map", "[film]", "-map", "0:a:0",
|
|
147
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
|
|
148
|
+
"-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
|
|
149
|
+
"-c:a", "copy", "-metadata:s:a:0", `language=${languageTag}`, "-metadata:s:v:0", `language=${languageTag}`,
|
|
150
|
+
"-movflags", "+faststart", watermarkedPartial,
|
|
151
|
+
], { stdio: ["pipe", "ignore", "pipe"] });
|
|
152
|
+
let log = "";
|
|
153
|
+
child.stderr.on("data", (chunk) => { log = (log + chunk.toString()).slice(-8000); });
|
|
154
|
+
// An FFmpeg that fails early closes its input; its exit code reports why.
|
|
155
|
+
child.stdin.on("error", () => {});
|
|
156
|
+
child.stdin.end(badge.image);
|
|
157
|
+
const done = new Promise((resolve, reject) => {
|
|
158
|
+
child.once("error", reject);
|
|
159
|
+
child.once("close", (code, signal) => code === 0 ? resolve() : reject(new Error(`FFmpeg could not make the watermarked copy (${signal ?? `exit ${code}`}): ${log}`)));
|
|
160
|
+
});
|
|
161
|
+
watermarking = { child, done };
|
|
162
|
+
await done;
|
|
163
|
+
}
|
|
164
|
+
|
|
106
165
|
async function cleanup(error) {
|
|
107
166
|
if (aborted || finished) return;
|
|
108
167
|
aborted = true;
|
|
109
168
|
console.error(`Export stopped: ${error.message ?? error}`);
|
|
169
|
+
// SIGKILL, because an FFmpeg stopped any other way finishes the MP4 and would write the partial copy again; and at
|
|
170
|
+
// once, because Playwright's own Ctrl+C handler exits as soon as the headless browser has closed.
|
|
171
|
+
const marker = watermarking?.child;
|
|
172
|
+
if (marker && marker.exitCode === null && marker.signalCode === null) marker.kill("SIGKILL");
|
|
173
|
+
if (ownsWatermarkedPartial) await rm(watermarkedPartial, { force: true });
|
|
110
174
|
if (encoder && encoder.exitCode === null) {
|
|
111
175
|
encoder.stdin.destroy();
|
|
112
176
|
encoder.kill("SIGTERM");
|
|
@@ -156,8 +220,17 @@ async function route(request, response, url) {
|
|
|
156
220
|
encoder.once("close", (code) => code === 0 ? resolve() : reject(new Error(`FFmpeg exited ${code}: ${encoderLog}`)));
|
|
157
221
|
});
|
|
158
222
|
encoderDone.catch((error) => { encoderError = error; });
|
|
159
|
-
console.log(`Rendering ${language.toUpperCase()}: ${settings.frames} frames (${settings.duration}s), ${width} × ${height} / ${fps} fps, ${subtitles.length ? `${subtitles.length} captions ${burnCaptions ? "drawn into the picture" : "as subtitle files"}` : "no captions"}.`);
|
|
160
|
-
send({ fps, frames: settings.frames, burnCaptions, thumbnail }); return;
|
|
223
|
+
console.log(`Rendering ${language.toUpperCase()}: ${settings.frames} frames (${settings.duration}s), ${width} × ${height} / ${fps} fps, ${subtitles.length ? `${subtitles.length} captions ${burnCaptions ? "drawn into the picture" : "as subtitle files"}` : "no captions"}${watermark ? ", plus a watermarked copy" : ""}.`);
|
|
224
|
+
send({ fps, frames: settings.frames, burnCaptions, thumbnail, watermark: watermarkFiles }); return;
|
|
225
|
+
}
|
|
226
|
+
if (url.pathname === "/__export/watermark") {
|
|
227
|
+
if (!watermark || !encoder || encoding || badge || received) throw new Error("Send the watermark once, before the first frame.");
|
|
228
|
+
const image = await readBody(request, 1024 * 1024);
|
|
229
|
+
const size = pngDimensions(image);
|
|
230
|
+
const x = Number(url.searchParams.get("x")), y = Number(url.searchParams.get("y"));
|
|
231
|
+
if (request.headers["content-type"] !== "image/png" || !size || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x + size.width > width || y + size.height > height) throw new Error("Expected a PNG watermark inside the picture.");
|
|
232
|
+
badge = { image, x, y, ...size };
|
|
233
|
+
send({ bytes: image.length }); return;
|
|
161
234
|
}
|
|
162
235
|
if (url.pathname === "/__export/frame") {
|
|
163
236
|
if (!encoder || encoding || finished || aborted) throw new Error("No active frame capture.");
|
|
@@ -196,30 +269,34 @@ async function route(request, response, url) {
|
|
|
196
269
|
if (url.pathname === "/__export/finish") {
|
|
197
270
|
if (!encoder || encoding || received !== settings.frames) throw new Error("The capture is incomplete.");
|
|
198
271
|
if (thumbnail && !thumbnailImage) throw new Error("The thumbnail was not captured.");
|
|
272
|
+
if (watermark && !badge) throw new Error("The watermark was not drawn.");
|
|
199
273
|
encoding = true;
|
|
200
274
|
encoder.stdin.end();
|
|
201
275
|
await encoderDone;
|
|
202
276
|
console.log("Checking the encoded video and soundtrack…");
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
277
|
+
const probe = await verify(partial, "file");
|
|
278
|
+
if (watermark) {
|
|
279
|
+
console.log("Making the watermarked copy…");
|
|
280
|
+
await watermarkCopy();
|
|
281
|
+
await verify(watermarkedPartial, "watermarked copy");
|
|
282
|
+
}
|
|
208
283
|
await rename(partial, destination);
|
|
284
|
+
if (watermark) await rename(watermarkedPartial, watermarked);
|
|
209
285
|
// A player would show a same-named subtitle file on top of burned-in captions, so those exports get none.
|
|
210
286
|
const subtitleFiles = subtitles.length && !burnCaptions ? [[".srt", toSrt(subtitles)], [".vtt", toVtt(subtitles, language)]].map(([extension, text]) => ({ path: destination.replace(/\.mp4$/, extension), text })) : [];
|
|
211
287
|
for (const file of subtitleFiles) await writeFile(file.path, file.text);
|
|
212
288
|
const thumbnailFile = thumbnailImage ? destination.replace(/\.mp4$/, ".jpg") : null;
|
|
213
289
|
if (thumbnailFile) await writeFile(thumbnailFile, thumbnailImage);
|
|
214
|
-
await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), title: config.title, audio: config.audio, subtitles: subtitleFiles.map((file) => path.basename(file.path)), thumbnail: thumbnailFile && { file: path.basename(thumbnailFile), time: thumbnail.time }, settings, verification: probe }, null, 2) + "\n");
|
|
290
|
+
await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), title: config.title, audio: config.audio, subtitles: subtitleFiles.map((file) => path.basename(file.path)), thumbnail: thumbnailFile && { file: path.basename(thumbnailFile), time: thumbnail.time }, ...(watermark ? { watermarked: { file: path.basename(watermarked), badge: { x: badge.x, y: badge.y, width: badge.width, height: badge.height } } } : {}), settings, verification: probe }, null, 2) + "\n");
|
|
215
291
|
const bytes = (await stat(destination)).size;
|
|
216
292
|
finished = true;
|
|
217
293
|
clearInterval(idleCheck);
|
|
218
294
|
console.log(`Saved: ${destination}\nSize: ${(bytes / 1024 / 1024).toFixed(1)} MiB\nVerified: ${settings.frames} frames, ${settings.duration}s, H.264 video and AAC audio.`);
|
|
219
295
|
if (subtitleFiles.length) console.log(`Subtitles (${subtitles.length} captions, ${language}): ${subtitleFiles.map((file) => file.path).join(", ")}`);
|
|
220
296
|
console.log(thumbnailFile ? `Thumbnail (${thumbnail.time}s): ${thumbnailFile}` : 'No thumbnail: set "poster" in film.config.json to the seconds of the frame to save as one.');
|
|
297
|
+
if (watermark) console.log(`Watermarked copy ("Made with AnimaGuru"): ${watermarked}`);
|
|
221
298
|
response.once("finish", () => { void server.close(); });
|
|
222
|
-
send({ path: destination, bytes, subtitles: subtitleFiles.map((file) => file.path), thumbnail: thumbnailFile }); return;
|
|
299
|
+
send({ path: destination, bytes, subtitles: subtitleFiles.map((file) => file.path), thumbnail: thumbnailFile, watermarked: watermark ? watermarked : null }); return;
|
|
223
300
|
}
|
|
224
301
|
if (url.pathname === "/__export/abort") {
|
|
225
302
|
const message = JSON.parse((await readBody(request, 4096)).toString());
|
|
@@ -244,13 +321,14 @@ server = await createServer({
|
|
|
244
321
|
});
|
|
245
322
|
} }],
|
|
246
323
|
});
|
|
324
|
+
// Finishing, which for a long film with a watermarked copy can take minutes, waits for no capture request.
|
|
247
325
|
const idleCheck = setInterval(() => {
|
|
248
|
-
if (Date.now() - lastActivity > 10 * 60 * 1000) void cleanup(new Error("No capture activity for ten minutes."));
|
|
326
|
+
if (!encoding && Date.now() - lastActivity > 10 * 60 * 1000) void cleanup(new Error("No capture activity for ten minutes."));
|
|
249
327
|
}, 30000);
|
|
250
328
|
process.once("SIGINT", () => { void cleanup(new Error("Cancelled.")); });
|
|
251
329
|
process.once("SIGTERM", () => { void cleanup(new Error("Cancelled.")); });
|
|
252
330
|
try { await server.listen(); } catch (error) { await cleanup(error); throw error; }
|
|
253
|
-
console.log(`Open this local export page and keep it open until complete:\nhttp://127.0.0.1:${port}${context.route}?capture=1&export=${token}\nOutput: ${destination}\nThe preview can stay open. This capture runs locally without generation credits.`);
|
|
331
|
+
console.log(`Open this local export page and keep it open until complete:\nhttp://127.0.0.1:${port}${context.route}?capture=1&export=${token}\nOutput: ${destination}${watermark ? `\nWatermarked copy: ${watermarked}` : ""}\nThe preview can stay open. This capture runs locally without generation credits.`);
|
|
254
332
|
if (headless) {
|
|
255
333
|
try { await captureHeadless(`http://127.0.0.1:${port}${context.route}?capture=1&export=${token}`, { endpoint: "/__export/finish" }); }
|
|
256
334
|
catch (error) { await cleanup(error); }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
Binary file
|
|
Binary file
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"preparing": "Preparing MP4 export…",
|
|
3
3
|
"rendering": "Rendering MP4 · {percent}% · {frame} / {total} frames. Keep this tab open.",
|
|
4
|
+
"watermark": "Drawing the Made with AnimaGuru watermark…",
|
|
4
5
|
"thumbnail": "Saving the thumbnail…",
|
|
5
6
|
"finishing": "Finishing the MP4 and checking its video and audio…",
|
|
6
7
|
"saved": "MP4 saved: {path}",
|
|
@@ -24,11 +24,13 @@ export async function exportMp4(api: CaptureApi, token: string) {
|
|
|
24
24
|
status.style.cssText = "position:fixed;z-index:100;left:24px;bottom:24px;padding:16px 22px;border-radius:12px;background:#162224ed;color:white;font:15px Arial,sans-serif;box-shadow:0 8px 30px #0002;max-width:calc(100vw - 48px)";
|
|
25
25
|
status.textContent = copy.preparing;
|
|
26
26
|
document.body.append(status);
|
|
27
|
+
// With --watermark, finishing also encodes the watermarked copy, which takes longer.
|
|
28
|
+
let finishTimeout = 180000;
|
|
27
29
|
const request = async (route: string, body?: BodyInit, type = "application/json") => {
|
|
28
30
|
const attempts = route.startsWith("frame?") ? 3 : 1;
|
|
29
31
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
30
32
|
try {
|
|
31
|
-
const response = await fetch(`/__export/${route}`, { method: "POST", headers: { "Content-Type": type, "X-AnimaGuru-Export": token }, body, signal: AbortSignal.timeout(route === "finish" ?
|
|
33
|
+
const response = await fetch(`/__export/${route}`, { method: "POST", headers: { "Content-Type": type, "X-AnimaGuru-Export": token }, body, signal: AbortSignal.timeout(route === "finish" ? finishTimeout : 45000) });
|
|
32
34
|
if (!response.ok) throw new Error(await response.text());
|
|
33
35
|
return await response.json();
|
|
34
36
|
} catch (error) {
|
|
@@ -46,6 +48,14 @@ export async function exportMp4(api: CaptureApi, token: string) {
|
|
|
46
48
|
const settings = await request("start", JSON.stringify({ width: api.width, height: api.height, duration: api.duration, fps: api.fps, language, subtitles }));
|
|
47
49
|
// The export command decides: captions drawn into the picture, or saved as .srt/.vtt files beside a clean MP4.
|
|
48
50
|
api.setCaptions(settings.burnCaptions === true);
|
|
51
|
+
if (settings.watermark) {
|
|
52
|
+
// --watermark: the badge is drawn here once, before any frame, and the export command lays it over a copy.
|
|
53
|
+
status.textContent = copy.watermark;
|
|
54
|
+
const { watermarkBadge } = await import("./watermark");
|
|
55
|
+
const badge = await watermarkBadge(api.width, api.height, settings.watermark);
|
|
56
|
+
await request(`watermark?x=${badge.x}&y=${badge.y}`, badge.image, "image/png");
|
|
57
|
+
finishTimeout = 15 * 60000;
|
|
58
|
+
}
|
|
49
59
|
state.total = settings.frames;
|
|
50
60
|
state.status = "rendering";
|
|
51
61
|
for (let frame = 0; frame < settings.frames; frame++) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The "Made with AnimaGuru" badge of an AnimaGuru lead film, drawn the way AnimaGuru's web app draws it in the viewer's
|
|
3
|
+
browser, with the same Instrument Sans and logo files, which the export command sends. `npm run export:mp4 --
|
|
4
|
+
--watermark` lays it over a copy of the film, so the copy matches one made on animaguru.com.
|
|
5
|
+
*/
|
|
6
|
+
const FAMILY = "AnimaGuru Watermark Sans";
|
|
7
|
+
export const WATERMARK_LEAD = "Made with ";
|
|
8
|
+
export const WATERMARK_NAME = "AnimaGuru";
|
|
9
|
+
|
|
10
|
+
/** The badge's sizes for a width×height picture, from its shorter side so portrait and landscape videos match. */
|
|
11
|
+
export function watermarkMetrics(width: number, height: number) {
|
|
12
|
+
const short = Math.min(width, height);
|
|
13
|
+
const font = Math.min(64, Math.max(12, Math.round(short * 0.028)));
|
|
14
|
+
const badge = Math.round(font * 2.1);
|
|
15
|
+
return { font, badge, logo: Math.round(font * 1.4), gap: Math.round(font * 0.45), padding: Math.round(font * 0.75), margin: Math.max(8, Math.round(short * 0.035)) };
|
|
16
|
+
}
|
|
17
|
+
/** Where a badge of `size` goes in the picture: the bottom-right corner, shrunk when it would cover too much of a small video. */
|
|
18
|
+
export function watermarkPlacement(width: number, height: number, size: { width: number; height: number }, margin: number) {
|
|
19
|
+
const scale = Math.min(1, (width * 0.6) / size.width, (height * 0.2) / size.height);
|
|
20
|
+
const w = Math.round(size.width * scale), h = Math.round(size.height * scale);
|
|
21
|
+
return { x: Math.round(width - margin - w), y: Math.round(height - margin - h), width: w, height: h };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const bytes = (base64: string) => Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
|
25
|
+
|
|
26
|
+
/** The badge for a width×height film as a PNG at its final size, and where it goes. `files` holds the font and logo as base64. */
|
|
27
|
+
export async function watermarkBadge(width: number, height: number, files: { font: string; logo: string }) {
|
|
28
|
+
const size = watermarkMetrics(width, height);
|
|
29
|
+
document.fonts.add(await new FontFace(FAMILY, bytes(files.font), { weight: "400 700" }).load());
|
|
30
|
+
const lead = `500 ${size.font}px "${FAMILY}"`, name = `700 ${size.font}px "${FAMILY}"`;
|
|
31
|
+
const logo = await createImageBitmap(new Blob([bytes(files.logo)], { type: "image/png" }));
|
|
32
|
+
const measure = new OffscreenCanvas(1, 1).getContext("2d")!;
|
|
33
|
+
measure.font = lead; const leadWidth = measure.measureText(WATERMARK_LEAD).width;
|
|
34
|
+
measure.font = name; const nameWidth = measure.measureText(WATERMARK_NAME).width;
|
|
35
|
+
const inset = (size.badge - size.logo) / 2;
|
|
36
|
+
const canvas = new OffscreenCanvas(Math.ceil(inset + size.logo + size.gap + leadWidth + nameWidth + size.padding), size.badge);
|
|
37
|
+
const context = canvas.getContext("2d")!;
|
|
38
|
+
context.fillStyle = "rgba(12, 14, 18, 0.58)";
|
|
39
|
+
context.beginPath(); context.roundRect(0, 0, canvas.width, canvas.height, canvas.height / 2); context.fill();
|
|
40
|
+
context.drawImage(logo, inset, inset, size.logo, size.logo); logo.close();
|
|
41
|
+
context.textBaseline = "middle";
|
|
42
|
+
const x = inset + size.logo + size.gap, y = canvas.height / 2 + size.font * 0.04;
|
|
43
|
+
context.font = lead; context.fillStyle = "rgba(255, 255, 255, 0.82)"; context.fillText(WATERMARK_LEAD, x, y);
|
|
44
|
+
context.font = name; context.fillStyle = "#ffffff"; context.fillText(WATERMARK_NAME, x + leadWidth, y);
|
|
45
|
+
// The web app scales the badge as it draws it on each frame; here it is scaled once, so FFmpeg lays it over as it is.
|
|
46
|
+
const place = watermarkPlacement(width, height, canvas, size.margin);
|
|
47
|
+
const placed = new OffscreenCanvas(place.width, place.height);
|
|
48
|
+
placed.getContext("2d")!.drawImage(canvas, 0, 0, place.width, place.height);
|
|
49
|
+
return { image: await placed.convertToBlob({ type: "image/png" }), x: place.x, y: place.y, width: place.width, height: place.height };
|
|
50
|
+
}
|