@pilio/gemini-watermark-remover 1.0.19 → 1.0.21
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/README.md +17 -10
- package/README_zh.md +8 -1
- package/package.json +74 -3
- package/src/cli/gwrCli.js +1 -1
- package/src/cli/gwrRemoveCommand.js +82 -14
- package/src/core/adaptiveDetector.js +22 -3
- package/src/core/allenkFdncnnDenoise.js +326 -0
- package/src/core/allenkFdncnnNcnnModel.js +244 -0
- package/src/core/allenkFdncnnOnnxExport.js +337 -0
- package/src/core/allenkFdncnnOnnxRuntime.js +138 -0
- package/src/core/allenkFdncnnReferenceRuntime.js +169 -0
- package/src/core/alphaGradientMask.js +165 -0
- package/src/core/blendModes.js +9 -4
- package/src/core/candidateSelector.js +422 -33
- package/src/core/embeddedAlphaMaps.js +2 -0
- package/src/core/geminiSizeCatalog.js +44 -1
- package/src/core/restorationMetrics.js +44 -0
- package/src/core/watermarkEngine.js +2 -1
- package/src/core/watermarkProcessor.js +1048 -82
- package/src/sdk/index.d.ts +29 -1
- package/src/sdk/node.d.ts +13 -0
- package/src/sdk/node.js +7 -0
- package/src/sdk/video.d.ts +84 -0
- package/src/sdk/video.js +263 -0
- package/src/shared/debugFileHandoff.js +109 -0
package/src/sdk/index.d.ts
CHANGED
|
@@ -45,6 +45,31 @@ export interface WatermarkConfig {
|
|
|
45
45
|
logoSize: number;
|
|
46
46
|
marginRight: number;
|
|
47
47
|
marginBottom: number;
|
|
48
|
+
alphaVariant?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface WatermarkHaloMeta {
|
|
52
|
+
bandCount: number;
|
|
53
|
+
outerCount: number;
|
|
54
|
+
bandMeanLum: number;
|
|
55
|
+
outerMeanLum: number;
|
|
56
|
+
bandStdLum: number;
|
|
57
|
+
outerStdLum: number;
|
|
58
|
+
deltaLum: number;
|
|
59
|
+
positiveDeltaLum: number;
|
|
60
|
+
visibility: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface WatermarkResidualVisibilityMeta {
|
|
64
|
+
visible: boolean;
|
|
65
|
+
positiveHaloLum: number;
|
|
66
|
+
haloVisibility: number;
|
|
67
|
+
spatialResidual: number;
|
|
68
|
+
gradientResidual: number;
|
|
69
|
+
visiblePositiveHalo: boolean;
|
|
70
|
+
visibleGradientResidual: boolean;
|
|
71
|
+
visibleSpatialResidual: boolean;
|
|
72
|
+
halo?: WatermarkHaloMeta;
|
|
48
73
|
}
|
|
49
74
|
|
|
50
75
|
export interface WatermarkDetectionMeta {
|
|
@@ -54,6 +79,7 @@ export interface WatermarkDetectionMeta {
|
|
|
54
79
|
processedSpatialScore: number | null;
|
|
55
80
|
processedGradientScore: number | null;
|
|
56
81
|
suppressionGain: number | null;
|
|
82
|
+
residualVisibility?: WatermarkResidualVisibilityMeta | null;
|
|
57
83
|
}
|
|
58
84
|
|
|
59
85
|
export interface WatermarkSelectionDebug {
|
|
@@ -91,10 +117,12 @@ export interface WatermarkMeta {
|
|
|
91
117
|
|
|
92
118
|
export interface RemoveOptions {
|
|
93
119
|
adaptiveMode?: 'auto' | 'always' | 'never' | 'off';
|
|
120
|
+
aggressiveLocatedFallback?: boolean;
|
|
121
|
+
locatedAggressiveRemoval?: boolean;
|
|
94
122
|
engine?: WatermarkEngine;
|
|
95
123
|
alpha48?: Float32Array;
|
|
96
124
|
alpha96?: Float32Array;
|
|
97
|
-
getAlphaMap?: (size: number) => Float32Array;
|
|
125
|
+
getAlphaMap?: (size: number | string) => Float32Array;
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
export interface ImageDataRemovalResult {
|
package/src/sdk/node.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { ImageDataRemovalResult, RemoveOptions, WatermarkMeta } from './index.js';
|
|
2
|
+
export type {
|
|
3
|
+
VideoBufferRemovalOptions,
|
|
4
|
+
VideoBufferRemovalResult,
|
|
5
|
+
VideoFileRemovalOptions,
|
|
6
|
+
VideoFileRemovalResult,
|
|
7
|
+
VideoRemovalMeta
|
|
8
|
+
} from './video.js';
|
|
2
9
|
|
|
3
10
|
export interface NodeCodecContext {
|
|
4
11
|
mimeType: string;
|
|
@@ -40,3 +47,9 @@ export function removeWatermarkFromFile(
|
|
|
40
47
|
inputPath: string,
|
|
41
48
|
options: NodeFileRemovalOptions
|
|
42
49
|
): Promise<NodeFileRemovalResult>;
|
|
50
|
+
export {
|
|
51
|
+
inferVideoMimeTypeFromPath,
|
|
52
|
+
isVideoMimeType,
|
|
53
|
+
removeVideoWatermarkFromBuffer,
|
|
54
|
+
removeVideoWatermarkFromFile
|
|
55
|
+
} from './video.js';
|
package/src/sdk/node.js
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface VideoRemovalMeta {
|
|
2
|
+
status?: string;
|
|
3
|
+
denoiseBackend?: string;
|
|
4
|
+
actualDenoiseBackend?: string;
|
|
5
|
+
actualControls?: Record<string, unknown>;
|
|
6
|
+
pagePath?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface VideoFileProcessorContext {
|
|
11
|
+
outputPath?: string | null;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
filePath: string;
|
|
14
|
+
pagePath?: string;
|
|
15
|
+
denoiseBackend?: string;
|
|
16
|
+
allowLowConfidence?: boolean;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
edgeDenoiseStrength?: number;
|
|
19
|
+
residualCleanupStrength?: number;
|
|
20
|
+
videoBitrate?: number;
|
|
21
|
+
adaptiveAlpha?: boolean;
|
|
22
|
+
alphaGain?: number;
|
|
23
|
+
alphaProfile?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VideoBufferProcessorContext extends Omit<VideoFileProcessorContext, 'filePath'> {
|
|
27
|
+
filePath?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VideoProcessorResult {
|
|
31
|
+
buffer?: Buffer | Uint8Array | ArrayBuffer;
|
|
32
|
+
meta?: VideoRemovalMeta | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface VideoFileRemovalOptions {
|
|
36
|
+
outputPath?: string | null;
|
|
37
|
+
mimeType?: string;
|
|
38
|
+
pagePath?: string;
|
|
39
|
+
denoiseBackend?: string;
|
|
40
|
+
allowLowConfidence?: boolean;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
edgeDenoiseStrength?: number;
|
|
43
|
+
residualCleanupStrength?: number;
|
|
44
|
+
videoBitrate?: number;
|
|
45
|
+
adaptiveAlpha?: boolean;
|
|
46
|
+
alphaGain?: number;
|
|
47
|
+
alphaProfile?: string;
|
|
48
|
+
processVideoFile?: (
|
|
49
|
+
inputPath: string,
|
|
50
|
+
context: VideoFileProcessorContext
|
|
51
|
+
) => Promise<VideoProcessorResult> | VideoProcessorResult;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface VideoBufferRemovalOptions extends VideoBufferProcessorContext {
|
|
55
|
+
processVideoBuffer: (
|
|
56
|
+
inputBuffer: Buffer,
|
|
57
|
+
context: VideoBufferProcessorContext
|
|
58
|
+
) => Promise<VideoProcessorResult> | VideoProcessorResult;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VideoFileRemovalResult {
|
|
62
|
+
buffer: Buffer;
|
|
63
|
+
outputPath: string | null;
|
|
64
|
+
mimeType: string;
|
|
65
|
+
meta: VideoRemovalMeta | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VideoBufferRemovalResult {
|
|
69
|
+
buffer: Buffer;
|
|
70
|
+
mimeType: string;
|
|
71
|
+
meta: VideoRemovalMeta | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function inferVideoMimeTypeFromPath(filePath: string): string;
|
|
75
|
+
export function isVideoMimeType(mimeType: string): boolean;
|
|
76
|
+
export function removeVideoWatermarkFromFile(
|
|
77
|
+
inputPath: string,
|
|
78
|
+
options?: VideoFileRemovalOptions
|
|
79
|
+
): Promise<VideoFileRemovalResult>;
|
|
80
|
+
export function removeVideoWatermarkFromBuffer(
|
|
81
|
+
inputBuffer: Buffer | Uint8Array | ArrayBuffer,
|
|
82
|
+
options: VideoBufferRemovalOptions
|
|
83
|
+
): Promise<VideoBufferRemovalResult>;
|
|
84
|
+
|
package/src/sdk/video.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_VIDEO_DENOISE_BACKEND = 'allenk-fdncnn-browser-spike';
|
|
6
|
+
const DEFAULT_VIDEO_TIMEOUT_MS = 6 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function normalizeBufferLike(value) {
|
|
9
|
+
if (Buffer.isBuffer(value)) return value;
|
|
10
|
+
if (value instanceof Uint8Array) return Buffer.from(value);
|
|
11
|
+
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
|
12
|
+
throw new TypeError('Expected Buffer, Uint8Array, or ArrayBuffer');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertFunction(value, name) {
|
|
16
|
+
if (typeof value !== 'function') {
|
|
17
|
+
throw new TypeError(`${name} must be a function`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isHttpUrl(value) {
|
|
22
|
+
return /^https?:\/\//i.test(String(value || ''));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveDefaultVideoPreviewPage() {
|
|
26
|
+
return path.resolve(process.cwd(), 'dist', 'video-preview.html');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function assertReadableFile(filePath, label) {
|
|
30
|
+
try {
|
|
31
|
+
await access(filePath);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`${label} is unavailable: ${filePath}`, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function inferVideoMimeTypeFromPath(filePath) {
|
|
38
|
+
const ext = path.extname(filePath || '').toLowerCase();
|
|
39
|
+
if (ext === '.mp4' || ext === '.m4v') return 'video/mp4';
|
|
40
|
+
if (ext === '.webm') return 'video/webm';
|
|
41
|
+
if (ext === '.mov') return 'video/quicktime';
|
|
42
|
+
return 'application/octet-stream';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isVideoMimeType(mimeType) {
|
|
46
|
+
return String(mimeType || '').toLowerCase().startsWith('video/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function setControlValue(page, selector, value) {
|
|
50
|
+
await page.evaluate(({ selector: targetSelector, value: targetValue }) => {
|
|
51
|
+
const control = document.querySelector(targetSelector);
|
|
52
|
+
if (!control) throw new Error(`Cannot find control: ${targetSelector}`);
|
|
53
|
+
control.value = String(targetValue);
|
|
54
|
+
control.dispatchEvent(new Event('input', { bubbles: true }));
|
|
55
|
+
control.dispatchEvent(new Event('change', { bubbles: true }));
|
|
56
|
+
}, { selector, value });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function setCheckboxValue(page, selector, checked) {
|
|
60
|
+
await page.evaluate(({ selector: targetSelector, checked: targetChecked }) => {
|
|
61
|
+
const checkbox = document.querySelector(targetSelector);
|
|
62
|
+
if (!checkbox) throw new Error(`Cannot find control: ${targetSelector}`);
|
|
63
|
+
checkbox.checked = Boolean(targetChecked);
|
|
64
|
+
checkbox.dispatchEvent(new Event('input', { bubbles: true }));
|
|
65
|
+
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
|
66
|
+
}, { selector, checked });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function setNumericInputValue(page, selector, value) {
|
|
70
|
+
await page.evaluate(({ selector: targetSelector, value: targetValue }) => {
|
|
71
|
+
const input = document.querySelector(targetSelector);
|
|
72
|
+
if (!input) throw new Error(`Cannot find control: ${targetSelector}`);
|
|
73
|
+
if (input.hasAttribute('max') && Number(targetValue) > Number(input.getAttribute('max'))) {
|
|
74
|
+
input.setAttribute('max', String(targetValue));
|
|
75
|
+
}
|
|
76
|
+
input.value = String(targetValue);
|
|
77
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
78
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
79
|
+
}, { selector, value });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readDownloadBufferFromPage(page) {
|
|
83
|
+
const base64 = await page.evaluate(async () => {
|
|
84
|
+
const link = document.getElementById('downloadBtn');
|
|
85
|
+
if (!link?.href || link.getAttribute('aria-disabled') === 'true') {
|
|
86
|
+
throw new Error('Video export has no downloadable result');
|
|
87
|
+
}
|
|
88
|
+
const blob = await fetch(link.href).then((response) => response.blob());
|
|
89
|
+
const reader = new FileReader();
|
|
90
|
+
return await new Promise((resolve, reject) => {
|
|
91
|
+
reader.onerror = () => reject(reader.error);
|
|
92
|
+
reader.onload = () => {
|
|
93
|
+
const result = String(reader.result || '');
|
|
94
|
+
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
95
|
+
};
|
|
96
|
+
reader.readAsDataURL(blob);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
return Buffer.from(base64, 'base64');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function collectVideoControls(page) {
|
|
103
|
+
return page.evaluate(() => ({
|
|
104
|
+
denoiseBackend: document.getElementById('denoiseBackend')?.value || '',
|
|
105
|
+
edgeDenoiseStrength: Number(document.getElementById('edgeDenoiseStrength')?.value),
|
|
106
|
+
residualCleanupStrength: Number(document.getElementById('residualCleanup')?.value),
|
|
107
|
+
videoBitrateMbps: Number(document.getElementById('videoBitrateMbps')?.value),
|
|
108
|
+
allowLowConfidence: Boolean(document.getElementById('allowLowConfidence')?.checked)
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function processVideoWithPreviewPage(inputPath, options = {}) {
|
|
113
|
+
const {
|
|
114
|
+
pagePath = resolveDefaultVideoPreviewPage(),
|
|
115
|
+
denoiseBackend = DEFAULT_VIDEO_DENOISE_BACKEND,
|
|
116
|
+
allowLowConfidence = false,
|
|
117
|
+
timeoutMs = DEFAULT_VIDEO_TIMEOUT_MS,
|
|
118
|
+
edgeDenoiseStrength,
|
|
119
|
+
residualCleanupStrength,
|
|
120
|
+
videoBitrate,
|
|
121
|
+
adaptiveAlpha = false,
|
|
122
|
+
alphaGain,
|
|
123
|
+
alphaProfile
|
|
124
|
+
} = options;
|
|
125
|
+
|
|
126
|
+
if (!isHttpUrl(pagePath)) {
|
|
127
|
+
await assertReadableFile(pagePath, 'Video preview page');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { chromium } = await import('playwright').catch((error) => {
|
|
131
|
+
throw new Error('Video processing requires the optional "playwright" dependency', { cause: error });
|
|
132
|
+
});
|
|
133
|
+
const browser = await chromium.launch({ headless: true });
|
|
134
|
+
try {
|
|
135
|
+
const page = await browser.newPage();
|
|
136
|
+
page.setDefaultTimeout(timeoutMs);
|
|
137
|
+
await page.goto(isHttpUrl(pagePath) ? pagePath : pathToFileURL(pagePath).href);
|
|
138
|
+
await page.locator('#fileInput').setInputFiles(inputPath);
|
|
139
|
+
await setControlValue(page, '#denoiseBackend', denoiseBackend);
|
|
140
|
+
|
|
141
|
+
if (adaptiveAlpha) {
|
|
142
|
+
await setCheckboxValue(page, '#adaptiveAlpha', true);
|
|
143
|
+
}
|
|
144
|
+
if (allowLowConfidence) {
|
|
145
|
+
await page.evaluate(() => {
|
|
146
|
+
window.__gwrVideoOverrideAllowLowConfidence = true;
|
|
147
|
+
});
|
|
148
|
+
await setCheckboxValue(page, '#allowLowConfidence', true);
|
|
149
|
+
}
|
|
150
|
+
if (Number.isFinite(alphaGain) && alphaGain > 0) {
|
|
151
|
+
await setNumericInputValue(page, '#alphaGain', Math.max(0.25, Math.min(1.35, alphaGain)));
|
|
152
|
+
}
|
|
153
|
+
if (typeof alphaProfile === 'string' && alphaProfile) {
|
|
154
|
+
await page.evaluate((value) => {
|
|
155
|
+
window.__gwrVideoAlphaProfile = value;
|
|
156
|
+
}, alphaProfile);
|
|
157
|
+
}
|
|
158
|
+
if (Number.isFinite(edgeDenoiseStrength)) {
|
|
159
|
+
const value = Math.max(0, Math.min(3, edgeDenoiseStrength));
|
|
160
|
+
await page.evaluate((nextValue) => {
|
|
161
|
+
window.__gwrVideoOverrideEdgeDenoiseStrength = nextValue;
|
|
162
|
+
}, value);
|
|
163
|
+
await setNumericInputValue(page, '#edgeDenoiseStrength', value);
|
|
164
|
+
}
|
|
165
|
+
if (Number.isFinite(residualCleanupStrength)) {
|
|
166
|
+
const value = Math.max(0, Math.min(1.8, residualCleanupStrength));
|
|
167
|
+
await page.evaluate((nextValue) => {
|
|
168
|
+
window.__gwrVideoOverrideResidualCleanupStrength = nextValue;
|
|
169
|
+
}, value);
|
|
170
|
+
await setNumericInputValue(page, '#residualCleanup', value);
|
|
171
|
+
}
|
|
172
|
+
if (Number.isFinite(videoBitrate) && videoBitrate > 0) {
|
|
173
|
+
await setNumericInputValue(page, '#videoBitrateMbps', videoBitrate / 1000 / 1000);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await page.locator('#processBtn').click();
|
|
177
|
+
await page.waitForFunction(() => {
|
|
178
|
+
const status = document.getElementById('status');
|
|
179
|
+
return status?.dataset?.tone === 'success' || status?.dataset?.tone === 'error';
|
|
180
|
+
}, null, { timeout: timeoutMs });
|
|
181
|
+
|
|
182
|
+
const status = await page.locator('#status').textContent();
|
|
183
|
+
const tone = await page.locator('#status').getAttribute('data-tone');
|
|
184
|
+
if (tone !== 'success') {
|
|
185
|
+
throw new Error(status || 'Video export failed');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const controls = await collectVideoControls(page);
|
|
189
|
+
const buffer = await readDownloadBufferFromPage(page);
|
|
190
|
+
return {
|
|
191
|
+
buffer,
|
|
192
|
+
meta: {
|
|
193
|
+
status,
|
|
194
|
+
denoiseBackend,
|
|
195
|
+
actualDenoiseBackend: controls.denoiseBackend,
|
|
196
|
+
actualControls: controls,
|
|
197
|
+
pagePath
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
} finally {
|
|
201
|
+
await browser.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function removeVideoWatermarkFromFile(inputPath, options = {}) {
|
|
206
|
+
const {
|
|
207
|
+
outputPath = null,
|
|
208
|
+
mimeType = inferVideoMimeTypeFromPath(inputPath),
|
|
209
|
+
processVideoFile = null,
|
|
210
|
+
...videoOptions
|
|
211
|
+
} = options;
|
|
212
|
+
|
|
213
|
+
if (processVideoFile !== null) {
|
|
214
|
+
assertFunction(processVideoFile, 'processVideoFile');
|
|
215
|
+
const customResult = await processVideoFile(inputPath, {
|
|
216
|
+
...videoOptions,
|
|
217
|
+
outputPath,
|
|
218
|
+
mimeType,
|
|
219
|
+
filePath: inputPath
|
|
220
|
+
});
|
|
221
|
+
const buffer = customResult?.buffer
|
|
222
|
+
? normalizeBufferLike(customResult.buffer)
|
|
223
|
+
: outputPath
|
|
224
|
+
? await readFile(outputPath)
|
|
225
|
+
: null;
|
|
226
|
+
if (!buffer) {
|
|
227
|
+
throw new Error('processVideoFile must return a buffer or write outputPath');
|
|
228
|
+
}
|
|
229
|
+
if (outputPath && customResult?.buffer) {
|
|
230
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
231
|
+
await writeFile(outputPath, buffer);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
buffer,
|
|
235
|
+
outputPath,
|
|
236
|
+
mimeType,
|
|
237
|
+
meta: customResult?.meta ?? null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result = await processVideoWithPreviewPage(inputPath, videoOptions);
|
|
242
|
+
if (outputPath) {
|
|
243
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
244
|
+
await writeFile(outputPath, result.buffer);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
buffer: result.buffer,
|
|
248
|
+
outputPath,
|
|
249
|
+
mimeType,
|
|
250
|
+
meta: result.meta
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function removeVideoWatermarkFromBuffer(inputBuffer, options = {}) {
|
|
255
|
+
const { processVideoBuffer = null, ...videoOptions } = options;
|
|
256
|
+
assertFunction(processVideoBuffer, 'processVideoBuffer');
|
|
257
|
+
const result = await processVideoBuffer(normalizeBufferLike(inputBuffer), videoOptions);
|
|
258
|
+
return {
|
|
259
|
+
buffer: normalizeBufferLike(result?.buffer),
|
|
260
|
+
mimeType: videoOptions.mimeType || 'video/mp4',
|
|
261
|
+
meta: result?.meta ?? null
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const DB_NAME = 'gwr-debug-file-handoff';
|
|
2
|
+
const DB_VERSION = 1;
|
|
3
|
+
const STORE_NAME = 'files';
|
|
4
|
+
const LATEST_KEY = 'latest';
|
|
5
|
+
|
|
6
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
|
7
|
+
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
8
|
+
const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.m4v']);
|
|
9
|
+
|
|
10
|
+
function getFileExtension(file) {
|
|
11
|
+
const name = typeof file?.name === 'string' ? file.name.toLowerCase() : '';
|
|
12
|
+
const dotIndex = name.lastIndexOf('.');
|
|
13
|
+
return dotIndex >= 0 ? name.slice(dotIndex) : '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getDebugFileKind(file) {
|
|
17
|
+
if (!file) return null;
|
|
18
|
+
|
|
19
|
+
const type = typeof file.type === 'string' ? file.type.toLowerCase() : '';
|
|
20
|
+
const extension = getFileExtension(file);
|
|
21
|
+
|
|
22
|
+
if (type.startsWith('video/') || VIDEO_EXTENSIONS.has(extension)) {
|
|
23
|
+
return 'video';
|
|
24
|
+
}
|
|
25
|
+
if (IMAGE_TYPES.has(type) || IMAGE_EXTENSIONS.has(extension)) {
|
|
26
|
+
return 'image';
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pickDebugUploadFile(files) {
|
|
32
|
+
const list = Array.from(files || []).filter(Boolean);
|
|
33
|
+
return (
|
|
34
|
+
list.find((file) => getDebugFileKind(file) === 'video')
|
|
35
|
+
|| list.find((file) => getDebugFileKind(file) === 'image')
|
|
36
|
+
|| null
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openHandoffDb() {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const indexedDb = globalThis.indexedDB;
|
|
43
|
+
if (!indexedDb) {
|
|
44
|
+
reject(new Error('当前浏览器不支持本地文件暂存,请直接打开目标调试页后重新选择文件。'));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const request = indexedDb.open(DB_NAME, DB_VERSION);
|
|
49
|
+
request.onupgradeneeded = () => {
|
|
50
|
+
const db = request.result;
|
|
51
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
52
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
request.onerror = () => reject(request.error || new Error('无法打开本地文件暂存。'));
|
|
56
|
+
request.onsuccess = () => resolve(request.result);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function saveDebugFileHandoff(file, targetKind = getDebugFileKind(file)) {
|
|
61
|
+
if (!file || !targetKind) {
|
|
62
|
+
throw new Error('不支持的文件类型。');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const record = {
|
|
66
|
+
id: LATEST_KEY,
|
|
67
|
+
kind: targetKind,
|
|
68
|
+
file,
|
|
69
|
+
name: file.name || '',
|
|
70
|
+
type: file.type || '',
|
|
71
|
+
size: Number.isFinite(file.size) ? file.size : 0,
|
|
72
|
+
updatedAt: Date.now()
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const db = await openHandoffDb();
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
|
78
|
+
transaction.objectStore(STORE_NAME).put(record);
|
|
79
|
+
transaction.oncomplete = () => resolve();
|
|
80
|
+
transaction.onerror = () => reject(transaction.error || new Error('本地文件暂存失败。'));
|
|
81
|
+
transaction.onabort = () => reject(transaction.error || new Error('本地文件暂存已取消。'));
|
|
82
|
+
}).finally(() => db.close());
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function consumeDebugFileHandoff(expectedKind = null) {
|
|
87
|
+
const db = await openHandoffDb();
|
|
88
|
+
let matchedRecord = null;
|
|
89
|
+
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
|
92
|
+
const store = transaction.objectStore(STORE_NAME);
|
|
93
|
+
const request = store.get(LATEST_KEY);
|
|
94
|
+
|
|
95
|
+
request.onsuccess = () => {
|
|
96
|
+
const record = request.result || null;
|
|
97
|
+
if (record && (!expectedKind || record.kind === expectedKind)) {
|
|
98
|
+
matchedRecord = record;
|
|
99
|
+
store.delete(LATEST_KEY);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
request.onerror = () => reject(request.error || new Error('读取本地文件暂存失败。'));
|
|
103
|
+
transaction.oncomplete = () => resolve();
|
|
104
|
+
transaction.onerror = () => reject(transaction.error || new Error('读取本地文件暂存失败。'));
|
|
105
|
+
transaction.onabort = () => reject(transaction.error || new Error('读取本地文件暂存已取消。'));
|
|
106
|
+
}).finally(() => db.close());
|
|
107
|
+
|
|
108
|
+
return matchedRecord;
|
|
109
|
+
}
|