@image-marker/web 0.1.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/CHANGELOG.md +11 -0
- package/README.md +100 -0
- package/api-contract.json +224 -0
- package/lib/commonjs/core/batch.d.ts +32 -0
- package/lib/commonjs/core/batch.js +85 -0
- package/lib/commonjs/core/content-credentials.d.ts +53 -0
- package/lib/commonjs/core/content-credentials.js +96 -0
- package/lib/commonjs/core/image-info.d.ts +30 -0
- package/lib/commonjs/core/image-info.js +348 -0
- package/lib/commonjs/core/index.d.ts +1014 -0
- package/lib/commonjs/core/index.js +92 -0
- package/lib/commonjs/core/invisible-watermark.d.ts +102 -0
- package/lib/commonjs/core/invisible-watermark.js +913 -0
- package/lib/commonjs/core/job.d.ts +57 -0
- package/lib/commonjs/core/job.js +151 -0
- package/lib/commonjs/core/layout.d.ts +19 -0
- package/lib/commonjs/core/layout.js +75 -0
- package/lib/commonjs/core/marker.d.ts +1 -0
- package/lib/commonjs/core/marker.js +11 -0
- package/lib/commonjs/core/recipe.d.ts +44 -0
- package/lib/commonjs/core/recipe.js +175 -0
- package/lib/commonjs/core/result.d.ts +34 -0
- package/lib/commonjs/core/result.js +41 -0
- package/lib/commonjs/core/trace-runtime.d.ts +43 -0
- package/lib/commonjs/core/trace-runtime.js +92 -0
- package/lib/commonjs/core/validate.d.ts +4 -0
- package/lib/commonjs/core/validate.js +192 -0
- package/lib/commonjs/core/web/browser.d.ts +105 -0
- package/lib/commonjs/core/web/browser.js +434 -0
- package/lib/commonjs/core/web/helpers.d.ts +41 -0
- package/lib/commonjs/core/web/helpers.js +264 -0
- package/lib/commonjs/core/web/index.d.ts +59 -0
- package/lib/commonjs/core/web/index.js +724 -0
- package/lib/commonjs/core/web/invisible-worker-client.d.ts +13 -0
- package/lib/commonjs/core/web/invisible-worker-client.js +140 -0
- package/lib/commonjs/core/web/renderer.d.ts +20 -0
- package/lib/commonjs/core/web/renderer.js +665 -0
- package/lib/commonjs/core/web/runtime.d.ts +106 -0
- package/lib/commonjs/core/web/runtime.js +2 -0
- package/lib/commonjs/editor/adapter.d.ts +26 -0
- package/lib/commonjs/editor/adapter.js +102 -0
- package/lib/commonjs/editor/controller.d.ts +106 -0
- package/lib/commonjs/editor/controller.js +919 -0
- package/lib/commonjs/editor/core-contract.d.ts +2 -0
- package/lib/commonjs/editor/core-contract.js +23 -0
- package/lib/commonjs/editor/geometry.d.ts +8 -0
- package/lib/commonjs/editor/geometry.js +109 -0
- package/lib/commonjs/editor/headless.d.ts +5 -0
- package/lib/commonjs/editor/headless.js +22 -0
- package/lib/commonjs/editor/presets.d.ts +19 -0
- package/lib/commonjs/editor/presets.js +64 -0
- package/lib/commonjs/editor/projection.d.ts +12 -0
- package/lib/commonjs/editor/projection.js +195 -0
- package/lib/commonjs/editor/types.d.ts +166 -0
- package/lib/commonjs/editor/types.js +2 -0
- package/lib/commonjs/editor-adapter.d.ts +7 -0
- package/lib/commonjs/editor-adapter.js +17 -0
- package/lib/commonjs/headless.d.ts +2 -0
- package/lib/commonjs/headless.js +26 -0
- package/lib/commonjs/index.d.ts +5 -0
- package/lib/commonjs/index.js +27 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/module/core/batch.d.ts +32 -0
- package/lib/module/core/batch.js +82 -0
- package/lib/module/core/content-credentials.d.ts +53 -0
- package/lib/module/core/content-credentials.js +92 -0
- package/lib/module/core/image-info.d.ts +30 -0
- package/lib/module/core/image-info.js +343 -0
- package/lib/module/core/index.d.ts +1014 -0
- package/lib/module/core/index.js +56 -0
- package/lib/module/core/invisible-watermark.d.ts +102 -0
- package/lib/module/core/invisible-watermark.js +897 -0
- package/lib/module/core/job.d.ts +57 -0
- package/lib/module/core/job.js +143 -0
- package/lib/module/core/layout.d.ts +19 -0
- package/lib/module/core/layout.js +70 -0
- package/lib/module/core/marker.d.ts +1 -0
- package/lib/module/core/marker.js +4 -0
- package/lib/module/core/recipe.d.ts +44 -0
- package/lib/module/core/recipe.js +169 -0
- package/lib/module/core/result.d.ts +34 -0
- package/lib/module/core/result.js +38 -0
- package/lib/module/core/trace-runtime.d.ts +43 -0
- package/lib/module/core/trace-runtime.js +89 -0
- package/lib/module/core/validate.d.ts +4 -0
- package/lib/module/core/validate.js +187 -0
- package/lib/module/core/web/browser.d.ts +105 -0
- package/lib/module/core/web/browser.js +427 -0
- package/lib/module/core/web/helpers.d.ts +41 -0
- package/lib/module/core/web/helpers.js +251 -0
- package/lib/module/core/web/index.d.ts +59 -0
- package/lib/module/core/web/index.js +709 -0
- package/lib/module/core/web/invisible-worker-client.d.ts +13 -0
- package/lib/module/core/web/invisible-worker-client.js +137 -0
- package/lib/module/core/web/renderer.d.ts +20 -0
- package/lib/module/core/web/renderer.js +661 -0
- package/lib/module/core/web/runtime.d.ts +106 -0
- package/lib/module/core/web/runtime.js +1 -0
- package/lib/module/editor/adapter.d.ts +26 -0
- package/lib/module/editor/adapter.js +99 -0
- package/lib/module/editor/controller.d.ts +106 -0
- package/lib/module/editor/controller.js +915 -0
- package/lib/module/editor/core-contract.d.ts +2 -0
- package/lib/module/editor/core-contract.js +2 -0
- package/lib/module/editor/geometry.d.ts +8 -0
- package/lib/module/editor/geometry.js +103 -0
- package/lib/module/editor/headless.d.ts +5 -0
- package/lib/module/editor/headless.js +4 -0
- package/lib/module/editor/presets.d.ts +19 -0
- package/lib/module/editor/presets.js +57 -0
- package/lib/module/editor/projection.d.ts +12 -0
- package/lib/module/editor/projection.js +188 -0
- package/lib/module/editor/types.d.ts +166 -0
- package/lib/module/editor/types.js +1 -0
- package/lib/module/editor-adapter.d.ts +7 -0
- package/lib/module/editor-adapter.js +10 -0
- package/lib/module/headless.d.ts +2 -0
- package/lib/module/headless.js +2 -0
- package/lib/module/index.d.ts +5 -0
- package/lib/module/index.js +3 -0
- package/lib/module/package.json +1 -0
- package/lib/source-manifest.json +34 -0
- package/lib/worker/invisible-watermark.js +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { createWatermarkRecipe, importWatermarkRecipe } from '../recipe.js';
|
|
2
|
+
import { renderWebComposition, renderWebCompositionToCanvas } from './renderer.js';
|
|
3
|
+
import { encodeCanvasToBlob } from './helpers.js';
|
|
4
|
+
import { encodeCanvas } from './helpers.js';
|
|
5
|
+
import { validateImageMarkOptions, validateMarkOptions, validateTextMarkOptions, } from '../validate.js';
|
|
6
|
+
import { getWebImageInfo } from './browser.js';
|
|
7
|
+
import { ImageMarkerError, createMarkerJobId, normalizeMarkerError, runControlledMarkerJob, runMarkerJob, } from '../job.js';
|
|
8
|
+
import { createMarkerResult } from '../result.js';
|
|
9
|
+
import { detectInvisibleWatermarkInWorker } from './invisible-worker-client.js';
|
|
10
|
+
import { runWatermarkBatch } from '../batch.js';
|
|
11
|
+
import { embedInvisibleWithCredentials, verifyContentCredentials, } from '../content-credentials.js';
|
|
12
|
+
import { detectInvisibleWatermarkPixelsAsync, embedInvisibleWatermarkPixels, validateDetectInvisibleOptions, validateEmbedInvisibleOptions, } from '../invisible-watermark.js';
|
|
13
|
+
function getPixelContext(canvas) {
|
|
14
|
+
const context = canvas.getContext('2d');
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error('The browser did not provide a 2D canvas context.');
|
|
17
|
+
}
|
|
18
|
+
return context;
|
|
19
|
+
}
|
|
20
|
+
function readImageData(canvas) {
|
|
21
|
+
try {
|
|
22
|
+
return getPixelContext(canvas).getImageData(0, 0, canvas.width, canvas.height);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const errorName = error && typeof error === 'object' && 'name' in error
|
|
26
|
+
? String(error.name)
|
|
27
|
+
: '';
|
|
28
|
+
if (errorName === 'SecurityError') {
|
|
29
|
+
throw new Error('Unable to read image pixels because a remote image tainted the canvas. Configure CORS (Access-Control-Allow-Origin) or use a local file/data URL.');
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function createTextLayer(options) {
|
|
35
|
+
return { type: 'text', options };
|
|
36
|
+
}
|
|
37
|
+
function createImageLayer(options) {
|
|
38
|
+
return { type: 'image', options };
|
|
39
|
+
}
|
|
40
|
+
function createOrderedLayer(layer) {
|
|
41
|
+
if (layer.type === 'text') {
|
|
42
|
+
return createTextLayer(layer);
|
|
43
|
+
}
|
|
44
|
+
if (layer.type === 'image') {
|
|
45
|
+
return createImageLayer(layer);
|
|
46
|
+
}
|
|
47
|
+
throw new Error('watermark type must be either "text" or "image".');
|
|
48
|
+
}
|
|
49
|
+
function appendCompatibilityLayers(layers, options) {
|
|
50
|
+
if ('watermarkTexts' in options) {
|
|
51
|
+
options.watermarkTexts?.forEach((text) => layers.push(createTextLayer(text)));
|
|
52
|
+
}
|
|
53
|
+
options.watermarkImages?.forEach((image) => layers.push(createImageLayer(image)));
|
|
54
|
+
}
|
|
55
|
+
function createMarkLayers(options) {
|
|
56
|
+
if (!options?.backgroundImage?.src) {
|
|
57
|
+
throw new Error('please set image!');
|
|
58
|
+
}
|
|
59
|
+
const layers = (options.watermarks?.length ?? 0) > 0
|
|
60
|
+
? options.watermarks.map(createOrderedLayer)
|
|
61
|
+
: [];
|
|
62
|
+
if (layers.length === 0) {
|
|
63
|
+
appendCompatibilityLayers(layers, options);
|
|
64
|
+
}
|
|
65
|
+
if (layers.length === 0) {
|
|
66
|
+
throw new Error('please set watermark text or image!');
|
|
67
|
+
}
|
|
68
|
+
if (layers.some((layer) => layer.type === 'image' && !layer.options.src)) {
|
|
69
|
+
throw new Error('please set mark image!');
|
|
70
|
+
}
|
|
71
|
+
validateMarkOptions(options);
|
|
72
|
+
return layers;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Public image-marking API shared by native and Web targets.
|
|
76
|
+
*
|
|
77
|
+
* Metro selects the native TurboModule implementation on iOS and Android.
|
|
78
|
+
* React Native Web and standard browser bundlers select this SSR-safe Canvas
|
|
79
|
+
* 2D implementation, which only touches DOM globals when a method is called.
|
|
80
|
+
*/
|
|
81
|
+
class Marker {
|
|
82
|
+
/** Read display dimensions, encoded dimensions, format, and orientation. */
|
|
83
|
+
static getImageInfo(source) {
|
|
84
|
+
return getWebImageInfo(source);
|
|
85
|
+
}
|
|
86
|
+
/** Embed a locator first, then ask the supplied adapter to sign the result. */
|
|
87
|
+
static embedInvisibleWithCredentials(options, control) {
|
|
88
|
+
return embedInvisibleWithCredentials((watermark) => Marker.embedInvisible(watermark, control), options);
|
|
89
|
+
}
|
|
90
|
+
/** Verify Content Credentials through an application-supplied adapter. */
|
|
91
|
+
static verifyContentCredentials(options) {
|
|
92
|
+
return verifyContentCredentials(options);
|
|
93
|
+
}
|
|
94
|
+
/** Embed authenticated locators into many images while preserving input order. */
|
|
95
|
+
static embedInvisibleMany(inputs, options) {
|
|
96
|
+
const snapshots = Array.isArray(inputs)
|
|
97
|
+
? inputs.map((input) => ({ ...input, image: { ...input?.image } }))
|
|
98
|
+
: inputs;
|
|
99
|
+
return runWatermarkBatch(snapshots, (input) => Marker.embedInvisible(input), options, 4, 'embedInvisibleMany');
|
|
100
|
+
}
|
|
101
|
+
/** Detect authenticated locators in many images while preserving input order. */
|
|
102
|
+
static detectInvisibleMany(inputs, options) {
|
|
103
|
+
const snapshots = Array.isArray(inputs)
|
|
104
|
+
? inputs.map((input) => ({ ...input, image: { ...input?.image } }))
|
|
105
|
+
: inputs;
|
|
106
|
+
return runWatermarkBatch(snapshots, (input) => Marker.detectInvisible(input), options, 4, 'detectInvisibleMany');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Embed a short, authenticated locator into the final image pixels.
|
|
110
|
+
*
|
|
111
|
+
* This Beta API supports distribution tracing. It is not DRM, encryption,
|
|
112
|
+
* or proof that the image was never edited.
|
|
113
|
+
*/
|
|
114
|
+
static async embedInvisible(options, control) {
|
|
115
|
+
return runMarkerJob({
|
|
116
|
+
descriptor: {
|
|
117
|
+
operation: 'embedInvisible',
|
|
118
|
+
saveFormat: options?.saveFormat,
|
|
119
|
+
filename: options?.filename,
|
|
120
|
+
},
|
|
121
|
+
control,
|
|
122
|
+
task: async () => {
|
|
123
|
+
validateEmbedInvisibleOptions(options);
|
|
124
|
+
const canvas = await renderWebCompositionToCanvas(options.image, [], {
|
|
125
|
+
quality: options.quality,
|
|
126
|
+
saveFormat: options.saveFormat,
|
|
127
|
+
maxSize: options.maxSize,
|
|
128
|
+
});
|
|
129
|
+
const context = getPixelContext(canvas);
|
|
130
|
+
const imageData = readImageData(canvas);
|
|
131
|
+
embedInvisibleWatermarkPixels({ data: imageData.data, width: canvas.width, height: canvas.height }, options);
|
|
132
|
+
context.putImageData(imageData, 0, 0);
|
|
133
|
+
return encodeCanvas(canvas, options.saveFormat, options.quality);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Detect and authenticate an invisible locator in an image without writing a file. */
|
|
138
|
+
static async detectInvisible(options, control) {
|
|
139
|
+
validateDetectInvisibleOptions(options);
|
|
140
|
+
const workerController = new AbortController();
|
|
141
|
+
const abortWorker = () => workerController.abort();
|
|
142
|
+
options.worker?.signal?.addEventListener('abort', abortWorker, {
|
|
143
|
+
once: true,
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const job = await runControlledMarkerJob({
|
|
147
|
+
operation: 'detectInvisible',
|
|
148
|
+
control,
|
|
149
|
+
workPhase: 'detecting',
|
|
150
|
+
cancel: abortWorker,
|
|
151
|
+
task: async () => {
|
|
152
|
+
const canvas = await renderWebCompositionToCanvas(options.image, [], {
|
|
153
|
+
saveFormat: undefined,
|
|
154
|
+
maxSize: options.maxSize,
|
|
155
|
+
});
|
|
156
|
+
const imageData = readImageData(canvas);
|
|
157
|
+
if (options.worker) {
|
|
158
|
+
return detectInvisibleWatermarkInWorker({
|
|
159
|
+
data: imageData.data,
|
|
160
|
+
width: canvas.width,
|
|
161
|
+
height: canvas.height,
|
|
162
|
+
}, options, { ...options.worker, signal: workerController.signal });
|
|
163
|
+
}
|
|
164
|
+
return detectInvisibleWatermarkPixelsAsync({
|
|
165
|
+
data: imageData.data,
|
|
166
|
+
width: canvas.width,
|
|
167
|
+
height: canvas.height,
|
|
168
|
+
}, options);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
...job.value,
|
|
173
|
+
jobId: job.jobId,
|
|
174
|
+
operation: 'detectInvisible',
|
|
175
|
+
durationMs: job.durationMs,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
options.worker?.signal?.removeEventListener('abort', abortWorker);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Save ordered layers and output settings for reuse across one or many images. */
|
|
183
|
+
static createRecipe(options, resultOptions) {
|
|
184
|
+
if (resultOptions?.resultType === 'blob') {
|
|
185
|
+
return createWatermarkRecipe(options, async (markOptions) => {
|
|
186
|
+
const canvas = await renderWebCompositionToCanvas(markOptions.backgroundImage, createMarkLayers(markOptions), markOptions);
|
|
187
|
+
return encodeCanvasToBlob(canvas, markOptions.saveFormat, markOptions.quality);
|
|
188
|
+
}, 4);
|
|
189
|
+
}
|
|
190
|
+
if (resultOptions?.resultType !== undefined &&
|
|
191
|
+
resultOptions.resultType !== 'result') {
|
|
192
|
+
throw new Error(`Unsupported recipe result type: ${resultOptions.resultType}.`);
|
|
193
|
+
}
|
|
194
|
+
return createWatermarkRecipe(options, (markOptions, control) => Marker.mark(markOptions, control), 4);
|
|
195
|
+
}
|
|
196
|
+
/** Import a persisted Recipe v1/v2 document, migrating v1 to v2 first. */
|
|
197
|
+
static importRecipe(document, resultOptions) {
|
|
198
|
+
if (resultOptions?.resultType === 'blob') {
|
|
199
|
+
return importWatermarkRecipe(document, async (markOptions) => {
|
|
200
|
+
const canvas = await renderWebCompositionToCanvas(markOptions.backgroundImage, createMarkLayers(markOptions), markOptions);
|
|
201
|
+
return encodeCanvasToBlob(canvas, markOptions.saveFormat, markOptions.quality);
|
|
202
|
+
}, 4);
|
|
203
|
+
}
|
|
204
|
+
if (resultOptions?.resultType !== undefined &&
|
|
205
|
+
resultOptions.resultType !== 'result') {
|
|
206
|
+
throw new Error(`Unsupported recipe result type: ${resultOptions.resultType}.`);
|
|
207
|
+
}
|
|
208
|
+
return importWatermarkRecipe(document, (markOptions, control) => Marker.mark(markOptions, control), 4);
|
|
209
|
+
}
|
|
210
|
+
/** Render one or more text watermark layers. */
|
|
211
|
+
static async markText(options, control) {
|
|
212
|
+
return runMarkerJob({
|
|
213
|
+
descriptor: {
|
|
214
|
+
operation: 'markText',
|
|
215
|
+
saveFormat: options?.saveFormat,
|
|
216
|
+
filename: options?.filename,
|
|
217
|
+
},
|
|
218
|
+
control,
|
|
219
|
+
task: async () => {
|
|
220
|
+
if (!options?.backgroundImage?.src) {
|
|
221
|
+
throw new Error('please set image!');
|
|
222
|
+
}
|
|
223
|
+
if (!options.watermarkTexts || options.watermarkTexts.length === 0) {
|
|
224
|
+
throw new Error('please set watermark text!');
|
|
225
|
+
}
|
|
226
|
+
validateTextMarkOptions(options);
|
|
227
|
+
return renderWebComposition(options.backgroundImage, options.watermarkTexts.map(createTextLayer), options);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Render one or more image watermark layers. */
|
|
232
|
+
static async markImage(options, control) {
|
|
233
|
+
return runMarkerJob({
|
|
234
|
+
descriptor: {
|
|
235
|
+
operation: 'markImage',
|
|
236
|
+
saveFormat: options?.saveFormat,
|
|
237
|
+
filename: options?.filename,
|
|
238
|
+
},
|
|
239
|
+
control,
|
|
240
|
+
task: async () => {
|
|
241
|
+
if (!options?.backgroundImage?.src) {
|
|
242
|
+
throw new Error('please set image!');
|
|
243
|
+
}
|
|
244
|
+
validateImageMarkOptions(options);
|
|
245
|
+
const watermarkImages = options.watermarkImages ?? [];
|
|
246
|
+
if (watermarkImages.length === 0) {
|
|
247
|
+
throw new Error('please set mark image!');
|
|
248
|
+
}
|
|
249
|
+
if (watermarkImages.some((watermark) => !watermark.src)) {
|
|
250
|
+
throw new Error('please set mark image!');
|
|
251
|
+
}
|
|
252
|
+
const layers = [];
|
|
253
|
+
appendCompatibilityLayers(layers, options);
|
|
254
|
+
return renderWebComposition(options.backgroundImage, layers, options);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** Render ordered mixed text and image watermark layers. */
|
|
259
|
+
static async mark(options, control) {
|
|
260
|
+
return runMarkerJob({
|
|
261
|
+
descriptor: {
|
|
262
|
+
operation: 'mark',
|
|
263
|
+
saveFormat: options?.saveFormat,
|
|
264
|
+
filename: options?.filename,
|
|
265
|
+
},
|
|
266
|
+
control,
|
|
267
|
+
task: async () => renderWebComposition(options.backgroundImage, createMarkLayers(options), options),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function runtimeAbortError(state) {
|
|
272
|
+
const stop = state.stop;
|
|
273
|
+
return new ImageMarkerError(stop?.code ?? 'ABORTED', stop?.message ?? 'Image marker operation was aborted.', {
|
|
274
|
+
jobId: state.taskId,
|
|
275
|
+
operation: state.operation === 'getImageInfo' ? 'mark' : state.operation,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function reportRuntimeProgress(control, taskId, operation, phase, progress) {
|
|
279
|
+
try {
|
|
280
|
+
control?.onProgress?.({
|
|
281
|
+
jobId: taskId,
|
|
282
|
+
operation,
|
|
283
|
+
phase,
|
|
284
|
+
completed: phase === 'completed' ? 1 : 0,
|
|
285
|
+
total: 1,
|
|
286
|
+
progress,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Progress observers cannot interrupt a runtime task.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function validateRuntimeControl(control) {
|
|
294
|
+
const timeout = control?.timeoutMs;
|
|
295
|
+
if (timeout !== undefined &&
|
|
296
|
+
(!Number.isFinite(timeout) || !Number.isInteger(timeout) || timeout <= 0)) {
|
|
297
|
+
throw new ImageMarkerError('INVALID_OPTIONS', 'timeoutMs must be a positive finite integer.');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function throwIfRuntimeAborted(signal) {
|
|
301
|
+
if (!signal.aborted)
|
|
302
|
+
return;
|
|
303
|
+
const error = new Error('Web Marker operation was aborted.');
|
|
304
|
+
error.name = 'AbortError';
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
function createWebMarkerCapabilities(hasHostAdapter) {
|
|
308
|
+
return Object.freeze({
|
|
309
|
+
visibleRendering: {
|
|
310
|
+
engine: 'canvas-2d',
|
|
311
|
+
cancellation: 'host-termination-required',
|
|
312
|
+
},
|
|
313
|
+
invisibleEmbedding: {
|
|
314
|
+
engine: 'canvas-2d-pixels',
|
|
315
|
+
cancellation: 'host-termination-required',
|
|
316
|
+
},
|
|
317
|
+
invisibleDetection: {
|
|
318
|
+
engine: 'dedicated-worker-when-configured-or-main-thread',
|
|
319
|
+
workerCancellation: 'cooperative',
|
|
320
|
+
mainThreadCancellation: 'host-termination-required',
|
|
321
|
+
},
|
|
322
|
+
execution: {
|
|
323
|
+
mode: hasHostAdapter
|
|
324
|
+
? 'host-adapter'
|
|
325
|
+
: 'default-dom',
|
|
326
|
+
supportsTerminationAcknowledgement: hasHostAdapter,
|
|
327
|
+
},
|
|
328
|
+
resources: {
|
|
329
|
+
localBlobInput: true,
|
|
330
|
+
blobOutput: true,
|
|
331
|
+
objectUrlCleanup: true,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Create an isolated Web Marker instance. The legacy static `Marker` API
|
|
337
|
+
* remains the default DOM path; use this factory when task cancellation and
|
|
338
|
+
* resource ownership need to be scoped to a desktop application instance.
|
|
339
|
+
*/
|
|
340
|
+
export function createWebMarker(options = {}) {
|
|
341
|
+
const activeTasks = new Set();
|
|
342
|
+
const resources = options.resources;
|
|
343
|
+
const execution = options.execution;
|
|
344
|
+
const capabilities = createWebMarkerCapabilities(Boolean(execution));
|
|
345
|
+
let isDisposed = false;
|
|
346
|
+
const disposeHostTask = async (state) => {
|
|
347
|
+
if (!state.hostTask || state.hostDisposed)
|
|
348
|
+
return;
|
|
349
|
+
state.hostDisposed = true;
|
|
350
|
+
await state.hostTask.dispose?.();
|
|
351
|
+
};
|
|
352
|
+
const requestStop = (state, stop) => {
|
|
353
|
+
if (!state.stop) {
|
|
354
|
+
state.stop = stop;
|
|
355
|
+
state.controller.abort();
|
|
356
|
+
}
|
|
357
|
+
if (!state.hostTask || state.termination) {
|
|
358
|
+
return state.termination ?? Promise.resolve();
|
|
359
|
+
}
|
|
360
|
+
const terminate = state.hostTask.terminate ?? state.hostTask.cancel;
|
|
361
|
+
state.termination = terminate
|
|
362
|
+
? new Promise((resolve, reject) => {
|
|
363
|
+
try {
|
|
364
|
+
resolve(terminate({ reason: state.stop.reason, taskId: state.taskId }));
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
reject(error);
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
: Promise.resolve();
|
|
371
|
+
return state.termination;
|
|
372
|
+
};
|
|
373
|
+
const runRuntimeTask = async (operation, resultKind, input, control, workPhase, local) => {
|
|
374
|
+
validateRuntimeControl(control);
|
|
375
|
+
const taskId = createMarkerJobId(operation === 'getImageInfo' ? 'mark' : operation);
|
|
376
|
+
const state = {
|
|
377
|
+
taskId,
|
|
378
|
+
operation,
|
|
379
|
+
controller: new AbortController(),
|
|
380
|
+
hostDisposed: false,
|
|
381
|
+
completion: Promise.resolve(),
|
|
382
|
+
settleCompletion: () => undefined,
|
|
383
|
+
};
|
|
384
|
+
state.completion = new Promise((resolve) => {
|
|
385
|
+
state.settleCompletion = resolve;
|
|
386
|
+
});
|
|
387
|
+
if (isDisposed) {
|
|
388
|
+
throw new ImageMarkerError('ABORTED', 'Web Marker instance is disposed.', {
|
|
389
|
+
jobId: taskId,
|
|
390
|
+
operation: operation === 'getImageInfo' ? 'mark' : operation,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (control?.signal?.aborted) {
|
|
394
|
+
throw new ImageMarkerError('ABORTED', 'Image marker operation was aborted before it started.', {
|
|
395
|
+
jobId: taskId,
|
|
396
|
+
operation: operation === 'getImageInfo' ? 'mark' : operation,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
const stopFromSignal = () => {
|
|
400
|
+
requestStop(state, {
|
|
401
|
+
code: 'ABORTED',
|
|
402
|
+
message: 'Image marker operation was aborted.',
|
|
403
|
+
reason: 'signal',
|
|
404
|
+
}).catch(() => {
|
|
405
|
+
// The active task reports a host termination failure when it settles.
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
control?.signal?.addEventListener('abort', stopFromSignal, { once: true });
|
|
409
|
+
let timeout;
|
|
410
|
+
if (control?.timeoutMs !== undefined) {
|
|
411
|
+
timeout = setTimeout(() => {
|
|
412
|
+
requestStop(state, {
|
|
413
|
+
code: 'TIMEOUT',
|
|
414
|
+
message: `Image marker operation timed out after ${control.timeoutMs}ms.`,
|
|
415
|
+
reason: 'timeout',
|
|
416
|
+
}).catch(() => {
|
|
417
|
+
// The active task reports a host termination failure when it settles.
|
|
418
|
+
});
|
|
419
|
+
}, control.timeoutMs);
|
|
420
|
+
}
|
|
421
|
+
activeTasks.add(state);
|
|
422
|
+
const startedAt = Date.now();
|
|
423
|
+
const markerOperation = operation === 'getImageInfo' ? 'mark' : operation;
|
|
424
|
+
reportRuntimeProgress(control, taskId, markerOperation, 'queued', 0);
|
|
425
|
+
reportRuntimeProgress(control, taskId, markerOperation, 'validating', 0.1);
|
|
426
|
+
reportRuntimeProgress(control, taskId, markerOperation, workPhase, 0.25);
|
|
427
|
+
try {
|
|
428
|
+
let value;
|
|
429
|
+
if (execution) {
|
|
430
|
+
const task = execution.start({
|
|
431
|
+
taskId,
|
|
432
|
+
operation,
|
|
433
|
+
resultKind,
|
|
434
|
+
options: input,
|
|
435
|
+
signal: state.controller.signal,
|
|
436
|
+
});
|
|
437
|
+
if (!task || !task.result || typeof task.result.then !== 'function') {
|
|
438
|
+
throw new Error('Web Marker execution adapter must return a task with a result Promise.');
|
|
439
|
+
}
|
|
440
|
+
state.hostTask = task;
|
|
441
|
+
if (state.stop) {
|
|
442
|
+
requestStop(state, state.stop).catch(() => {
|
|
443
|
+
// The active task reports a host termination failure when it settles.
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
value = await task.result;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
value = await local(state.controller.signal);
|
|
450
|
+
}
|
|
451
|
+
if (state.stop) {
|
|
452
|
+
await state.termination;
|
|
453
|
+
throw runtimeAbortError(state);
|
|
454
|
+
}
|
|
455
|
+
reportRuntimeProgress(control, taskId, markerOperation, 'completed', 1);
|
|
456
|
+
return {
|
|
457
|
+
taskId,
|
|
458
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
459
|
+
value,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
if (state.stop) {
|
|
464
|
+
try {
|
|
465
|
+
await state.termination;
|
|
466
|
+
}
|
|
467
|
+
catch (terminationError) {
|
|
468
|
+
throw normalizeMarkerError(terminationError, {
|
|
469
|
+
jobId: taskId,
|
|
470
|
+
operation: markerOperation,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
throw runtimeAbortError(state);
|
|
474
|
+
}
|
|
475
|
+
throw normalizeMarkerError(error, {
|
|
476
|
+
jobId: taskId,
|
|
477
|
+
operation: markerOperation,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
if (timeout !== undefined)
|
|
482
|
+
clearTimeout(timeout);
|
|
483
|
+
control?.signal?.removeEventListener('abort', stopFromSignal);
|
|
484
|
+
try {
|
|
485
|
+
await disposeHostTask(state);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// The task result remains authoritative after its host resource cleanup.
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
activeTasks.delete(state);
|
|
492
|
+
state.settleCompletion();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const renderRuntime = (signal) => ({
|
|
497
|
+
resources,
|
|
498
|
+
signal,
|
|
499
|
+
});
|
|
500
|
+
const stopAndWait = async (state, stop) => {
|
|
501
|
+
let terminationError;
|
|
502
|
+
try {
|
|
503
|
+
await requestStop(state, stop);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
terminationError = error;
|
|
507
|
+
}
|
|
508
|
+
await state.completion;
|
|
509
|
+
if (terminationError)
|
|
510
|
+
throw terminationError;
|
|
511
|
+
};
|
|
512
|
+
const runMarkerOperation = async (operation, input, control, local) => {
|
|
513
|
+
if (execution) {
|
|
514
|
+
return (await runRuntimeTask(operation, 'marker-result', input, control, 'rendering', async () => {
|
|
515
|
+
throw new Error('Host execution should have handled this task.');
|
|
516
|
+
})).value;
|
|
517
|
+
}
|
|
518
|
+
const completed = await runRuntimeTask(operation, 'marker-result', input, control, 'rendering', local);
|
|
519
|
+
return createMarkerResult(completed.taskId, completed.value, completed.durationMs, {
|
|
520
|
+
operation,
|
|
521
|
+
saveFormat: input.saveFormat,
|
|
522
|
+
filename: input.filename,
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
const markText = (markOptions, control) => runMarkerOperation('markText', markOptions, control, async (signal) => {
|
|
526
|
+
throwIfRuntimeAborted(signal);
|
|
527
|
+
if (!markOptions?.backgroundImage?.src) {
|
|
528
|
+
throw new Error('please set image!');
|
|
529
|
+
}
|
|
530
|
+
if (!markOptions.watermarkTexts ||
|
|
531
|
+
markOptions.watermarkTexts.length === 0) {
|
|
532
|
+
throw new Error('please set watermark text!');
|
|
533
|
+
}
|
|
534
|
+
validateTextMarkOptions(markOptions);
|
|
535
|
+
return renderWebComposition(markOptions.backgroundImage, markOptions.watermarkTexts.map(createTextLayer), markOptions, renderRuntime(signal));
|
|
536
|
+
});
|
|
537
|
+
const markImage = (markOptions, control) => runMarkerOperation('markImage', markOptions, control, async (signal) => {
|
|
538
|
+
throwIfRuntimeAborted(signal);
|
|
539
|
+
if (!markOptions?.backgroundImage?.src) {
|
|
540
|
+
throw new Error('please set image!');
|
|
541
|
+
}
|
|
542
|
+
validateImageMarkOptions(markOptions);
|
|
543
|
+
const watermarkImages = markOptions.watermarkImages ?? [];
|
|
544
|
+
if (watermarkImages.length === 0 ||
|
|
545
|
+
watermarkImages.some((watermark) => !watermark.src)) {
|
|
546
|
+
throw new Error('please set mark image!');
|
|
547
|
+
}
|
|
548
|
+
const layers = [];
|
|
549
|
+
appendCompatibilityLayers(layers, markOptions);
|
|
550
|
+
return renderWebComposition(markOptions.backgroundImage, layers, markOptions, renderRuntime(signal));
|
|
551
|
+
});
|
|
552
|
+
const mark = (markOptions, control) => runMarkerOperation('mark', markOptions, control, (signal) => {
|
|
553
|
+
throwIfRuntimeAborted(signal);
|
|
554
|
+
return renderWebComposition(markOptions.backgroundImage, createMarkLayers(markOptions), markOptions, renderRuntime(signal));
|
|
555
|
+
});
|
|
556
|
+
const embedInvisible = (embedOptions, control) => runMarkerOperation('embedInvisible', embedOptions, control, async (signal) => {
|
|
557
|
+
throwIfRuntimeAborted(signal);
|
|
558
|
+
validateEmbedInvisibleOptions(embedOptions);
|
|
559
|
+
const canvas = await renderWebCompositionToCanvas(embedOptions.image, [], {
|
|
560
|
+
quality: embedOptions.quality,
|
|
561
|
+
saveFormat: embedOptions.saveFormat,
|
|
562
|
+
maxSize: embedOptions.maxSize,
|
|
563
|
+
}, renderRuntime(signal));
|
|
564
|
+
throwIfRuntimeAborted(signal);
|
|
565
|
+
const context = getPixelContext(canvas);
|
|
566
|
+
const imageData = readImageData(canvas);
|
|
567
|
+
embedInvisibleWatermarkPixels({ data: imageData.data, width: canvas.width, height: canvas.height }, embedOptions);
|
|
568
|
+
context.putImageData(imageData, 0, 0);
|
|
569
|
+
throwIfRuntimeAborted(signal);
|
|
570
|
+
return encodeCanvas(canvas, embedOptions.saveFormat, embedOptions.quality);
|
|
571
|
+
});
|
|
572
|
+
const detectInvisible = async (detectOptions, control) => {
|
|
573
|
+
if (execution) {
|
|
574
|
+
return (await runRuntimeTask('detectInvisible', 'detection', detectOptions, control, 'detecting', async () => {
|
|
575
|
+
throw new Error('Host execution should have handled this task.');
|
|
576
|
+
})).value;
|
|
577
|
+
}
|
|
578
|
+
const completed = await runRuntimeTask('detectInvisible', 'detection', detectOptions, control, 'detecting', async (signal) => {
|
|
579
|
+
throwIfRuntimeAborted(signal);
|
|
580
|
+
validateDetectInvisibleOptions(detectOptions);
|
|
581
|
+
const workerController = new AbortController();
|
|
582
|
+
const abortWorker = () => workerController.abort();
|
|
583
|
+
signal.addEventListener('abort', abortWorker, { once: true });
|
|
584
|
+
detectOptions.worker?.signal?.addEventListener('abort', abortWorker, {
|
|
585
|
+
once: true,
|
|
586
|
+
});
|
|
587
|
+
try {
|
|
588
|
+
const canvas = await renderWebCompositionToCanvas(detectOptions.image, [], { saveFormat: undefined, maxSize: detectOptions.maxSize }, renderRuntime(signal));
|
|
589
|
+
const imageData = readImageData(canvas);
|
|
590
|
+
if (detectOptions.worker) {
|
|
591
|
+
return detectInvisibleWatermarkInWorker({
|
|
592
|
+
data: imageData.data,
|
|
593
|
+
width: canvas.width,
|
|
594
|
+
height: canvas.height,
|
|
595
|
+
}, detectOptions, { ...detectOptions.worker, signal: workerController.signal });
|
|
596
|
+
}
|
|
597
|
+
return detectInvisibleWatermarkPixelsAsync({
|
|
598
|
+
data: imageData.data,
|
|
599
|
+
width: canvas.width,
|
|
600
|
+
height: canvas.height,
|
|
601
|
+
}, detectOptions);
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
signal.removeEventListener('abort', abortWorker);
|
|
605
|
+
detectOptions.worker?.signal?.removeEventListener('abort', abortWorker);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
return {
|
|
609
|
+
...completed.value,
|
|
610
|
+
jobId: completed.taskId,
|
|
611
|
+
operation: 'detectInvisible',
|
|
612
|
+
durationMs: completed.durationMs,
|
|
613
|
+
};
|
|
614
|
+
};
|
|
615
|
+
const renderBlobRecipe = async (markOptions, control) => {
|
|
616
|
+
if (execution) {
|
|
617
|
+
return (await runRuntimeTask('mark', 'blob', markOptions, control, 'rendering', async () => {
|
|
618
|
+
throw new Error('Host execution should have handled this task.');
|
|
619
|
+
})).value;
|
|
620
|
+
}
|
|
621
|
+
return (await runRuntimeTask('mark', 'blob', markOptions, control, 'rendering', async (signal) => {
|
|
622
|
+
const canvas = await renderWebCompositionToCanvas(markOptions.backgroundImage, createMarkLayers(markOptions), markOptions, renderRuntime(signal));
|
|
623
|
+
throwIfRuntimeAborted(signal);
|
|
624
|
+
return encodeCanvasToBlob(canvas, markOptions.saveFormat, markOptions.quality);
|
|
625
|
+
})).value;
|
|
626
|
+
};
|
|
627
|
+
const createRecipe = (recipeOptions, resultOptions) => {
|
|
628
|
+
if (resultOptions?.resultType === 'blob') {
|
|
629
|
+
return createWatermarkRecipe(recipeOptions, renderBlobRecipe, 4);
|
|
630
|
+
}
|
|
631
|
+
if (resultOptions?.resultType !== undefined &&
|
|
632
|
+
resultOptions.resultType !== 'result') {
|
|
633
|
+
throw new Error(`Unsupported recipe result type: ${resultOptions.resultType}.`);
|
|
634
|
+
}
|
|
635
|
+
return createWatermarkRecipe(recipeOptions, (markOptions, control) => mark(markOptions, control), 4);
|
|
636
|
+
};
|
|
637
|
+
const importRecipe = (document, resultOptions) => {
|
|
638
|
+
if (resultOptions?.resultType === 'blob') {
|
|
639
|
+
return importWatermarkRecipe(document, renderBlobRecipe, 4);
|
|
640
|
+
}
|
|
641
|
+
if (resultOptions?.resultType !== undefined &&
|
|
642
|
+
resultOptions.resultType !== 'result') {
|
|
643
|
+
throw new Error(`Unsupported recipe result type: ${resultOptions.resultType}.`);
|
|
644
|
+
}
|
|
645
|
+
return importWatermarkRecipe(document, (markOptions, control) => mark(markOptions, control), 4);
|
|
646
|
+
};
|
|
647
|
+
const instance = {
|
|
648
|
+
capabilities,
|
|
649
|
+
get disposed() {
|
|
650
|
+
return isDisposed;
|
|
651
|
+
},
|
|
652
|
+
async getImageInfo(source) {
|
|
653
|
+
if (execution) {
|
|
654
|
+
return (await runRuntimeTask('getImageInfo', 'image-info', source, undefined, 'rendering', async () => {
|
|
655
|
+
throw new Error('Host execution should have handled this task.');
|
|
656
|
+
})).value;
|
|
657
|
+
}
|
|
658
|
+
return (await runRuntimeTask('getImageInfo', 'image-info', source, undefined, 'rendering', (signal) => {
|
|
659
|
+
throwIfRuntimeAborted(signal);
|
|
660
|
+
return getWebImageInfo(source, resources, signal);
|
|
661
|
+
})).value;
|
|
662
|
+
},
|
|
663
|
+
markText,
|
|
664
|
+
markImage,
|
|
665
|
+
mark,
|
|
666
|
+
embedInvisible,
|
|
667
|
+
detectInvisible,
|
|
668
|
+
embedInvisibleMany(inputs, batchOptions) {
|
|
669
|
+
const snapshots = Array.isArray(inputs)
|
|
670
|
+
? inputs.map((input) => ({ ...input, image: { ...input?.image } }))
|
|
671
|
+
: inputs;
|
|
672
|
+
return runWatermarkBatch(snapshots, (input) => embedInvisible(input, { signal: batchOptions?.signal }), batchOptions, 4, 'embedInvisibleMany');
|
|
673
|
+
},
|
|
674
|
+
detectInvisibleMany(inputs, batchOptions) {
|
|
675
|
+
const snapshots = Array.isArray(inputs)
|
|
676
|
+
? inputs.map((input) => ({ ...input, image: { ...input?.image } }))
|
|
677
|
+
: inputs;
|
|
678
|
+
return runWatermarkBatch(snapshots, (input) => detectInvisible(input, { signal: batchOptions?.signal }), batchOptions, 4, 'detectInvisibleMany');
|
|
679
|
+
},
|
|
680
|
+
createRecipe,
|
|
681
|
+
importRecipe,
|
|
682
|
+
embedInvisibleWithCredentials(credentialOptions, control) {
|
|
683
|
+
return embedInvisibleWithCredentials((watermark) => embedInvisible(watermark, control), credentialOptions);
|
|
684
|
+
},
|
|
685
|
+
verifyContentCredentials,
|
|
686
|
+
async cancel() {
|
|
687
|
+
await Promise.all(Array.from(activeTasks, (state) => stopAndWait(state, {
|
|
688
|
+
code: 'ABORTED',
|
|
689
|
+
message: 'Image marker operation was cancelled by its Web Marker instance.',
|
|
690
|
+
reason: 'cancel',
|
|
691
|
+
})));
|
|
692
|
+
},
|
|
693
|
+
async dispose() {
|
|
694
|
+
if (isDisposed && activeTasks.size === 0)
|
|
695
|
+
return;
|
|
696
|
+
isDisposed = true;
|
|
697
|
+
const tasks = Array.from(activeTasks);
|
|
698
|
+
await Promise.all(tasks.map((state) => stopAndWait(state, {
|
|
699
|
+
code: 'ABORTED',
|
|
700
|
+
message: 'Image marker operation was cancelled because its Web Marker instance was disposed.',
|
|
701
|
+
reason: 'dispose',
|
|
702
|
+
})));
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
return instance;
|
|
706
|
+
}
|
|
707
|
+
export { Marker, Marker as WebMarker };
|
|
708
|
+
export { degreesToRadians, encodeCanvas, encodeCanvasToBlob, fitSizeWithinMax, getExpandedCanvasSize, getRotatedBounds, normalizeOutputFormat, normalizeQuality, resolveAnchoredPosition, resolveSpreadValue, } from './helpers.js';
|
|
709
|
+
export default Marker;
|