openyida 2026.9.11 → 2026.9.13
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 +7 -5
- package/bin/yida.js +1 -0
- package/lib/ai/ai.js +2 -0
- package/lib/app/canvas-compile.js +29 -26
- package/lib/app/compile.js +39 -1
- package/lib/app/services/canvas-page-compiler.js +3 -3
- package/lib/app/update-app.js +17 -2
- package/lib/asset/ai-image.js +37 -65
- package/lib/asset/asset-cmd.js +151 -110
- package/lib/asset/asset-plan.js +105 -0
- package/lib/asset/asset-resolve.js +343 -225
- package/lib/asset/asset-status.js +20 -31
- package/lib/asset/attachment-upload.js +29 -0
- package/lib/asset/host-capabilities.js +91 -0
- package/lib/asset/image-metadata.js +104 -0
- package/lib/asset/url-verify.js +90 -130
- package/lib/core/agent-capabilities.js +11 -0
- package/lib/core/command-manifest.js +25 -11
- package/lib/core/locales/en.js +6 -2
- package/lib/core/locales/zh.js +6 -2
- package/lib/design-plan/materialize.js +7 -9
- package/lib/design-plan/normalize.js +11 -1
- package/lib/process/services/process-actions.js +145 -0
- package/lib/process/services/process-compiler.js +4 -30
- package/lib/process/services/process-view-verifier.js +31 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +1 -1
- package/yida-skills/SKILL.md +3 -2
- package/yida-skills/skills/yida-app/SKILL.md +1 -1
- package/yida-skills/skills/yida-app/workflow/step-2-design.md +11 -1
- package/yida-skills/skills/yida-app/workflow/step-3-create-or-reuse-app.md +21 -1
- package/yida-skills/skills/yida-app/workflow/step-5-seed-records.md +11 -7
- package/yida-skills/skills/yida-app/workflow/step-7-page-code.md +4 -0
- package/yida-skills/skills/yida-canvas-custom-page/SKILL.md +5 -3
- package/yida-skills/skills/yida-canvas-custom-page/references/component-library-guide.md +1 -1
- package/yida-skills/skills/yida-canvas-custom-page/references/page-generation-guide.md +3 -27
- package/yida-skills/skills/yida-canvas-table-form/SKILL.md +1 -1
- package/yida-skills/skills/yida-create-app/SKILL.md +4 -4
- package/yida-skills/skills/yida-create-process/SKILL.md +2 -0
- package/yida-skills/skills/yida-custom-page/references/assets-guide.md +9 -12
- package/yida-skills/skills/yida-design/SKILL.md +1 -0
- package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +4 -4
- package/yida-skills/skills/yida-design/references/asset-workflow.md +14 -53
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-compact-schema.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-schema.md +21 -4
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/render_build_plan.py +1 -2
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/validate_design_themes.py +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/README.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-media-grid.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-modular-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-structured-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ambient-halo-layered.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-focus-layered.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-luminous-modular.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/deep-stage-duotone.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/hairline-runway-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/high-contrast-modular.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/interlocked-vivid-modules.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/matrix-grid-focus.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/media-rail-inspector.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mist-layered-signal.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/modular-rail-signal.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mono-grid-signal.md +2 -2
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ribbon-ledger-lift.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/segmented-meter-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/split-rail-precision.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/status-framed-media-grid.md +1 -1
- package/yida-skills/skills/yida-design/workflow/output-design.md +4 -0
- package/yida-skills/skills/yida-design/workflow/step-4-wireframe-interaction.md +1 -1
- package/yida-skills/skills/yida-design/workflow/step-5-visual-states.md +2 -1
- package/yida-skills/skills/yida-image-assets/SKILL.md +91 -0
- package/yida-skills/skills/yida-image-assets/references/manifest-contract.md +87 -0
- package/yida-skills/skills/yida-image-assets/references/source-policy.md +19 -0
- package/yida-skills/skills/yida-nav-shell/references/nav-shell-patterns.md +1 -1
- package/yida-skills/skills/yida-prd/workflow/output-prd.md +2 -2
- package/yida-skills/skills/yida-process-rule/SKILL.md +6 -1
- package/yida-skills/skills/yida-process-rule/references/approval-actions.md +48 -0
- package/yida-skills/skills/yida-publish-page/SKILL.md +5 -4
- package/yida-skills/skills/yida-rechart/SKILL.md +1 -1
- package/yida-skills/skills/yida-requirement-analysis/SKILL.md +1 -1
- package/yida-skills/skills/yida-requirement-analysis/workflow/prepare-brief.md +22 -13
- package/yida-skills/skills-index.json +18 -1
|
@@ -1,31 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
/**
|
|
3
|
-
* asset-resolve.js - 素材解析与回填引擎
|
|
4
|
-
*
|
|
5
|
-
* P3 素材工具化的收口层:把 spec 里声明的图片素材(Hero / 产品图,可能是
|
|
6
|
-
* 本地路径、外链 URL 或空)解析为「可直接写进页面的稳定 URL」,并诚实地
|
|
7
|
-
* 给出素材完成度(materialStatus)与缺口(materialGaps),供生成链路决定
|
|
8
|
-
* 交付「最终版」还是「标注缺口的低保真草稿」。
|
|
9
|
-
*
|
|
10
|
-
* 解析规则(对每个候选图片):
|
|
11
|
-
* - 本地文件(存在于磁盘):
|
|
12
|
-
* · 有 CDN → 转存 CDN,替换为稳定 CDN URL(source: cdn);
|
|
13
|
-
* · 无 CDN → 无法交付浏览器可访问的稳定 URL → 记为缺口。
|
|
14
|
-
* - http(s) 外链:由 resolve 流程确认可用后保留(source: external);
|
|
15
|
-
* 有 CDN 且 options.mirror=true 时镜像转存;不可用时记为缺口。
|
|
16
|
-
* - 空 / 占位:记为对应槽位缺口。
|
|
17
|
-
*
|
|
18
|
-
* materialStatus:
|
|
19
|
-
* - 'final' : 所需素材(含 Hero,若 requireHero)均已解析为稳定 URL;
|
|
20
|
-
* - 'draft' : 页面可渲染但存在素材缺口(缺 Hero / 部分产品图失败);
|
|
21
|
-
* - 'none' : 无任何可用素材且需要图片 → 只能交付低保真草稿。
|
|
22
|
-
*
|
|
23
|
-
* 用法:
|
|
24
|
-
* const { resolveAssets } = require('./asset-resolve');
|
|
25
|
-
* const r = await resolveAssets(spec.assets, { requireHero: true });
|
|
26
|
-
* // { assets, materialStatus, materialGaps, strategy, actions, status }
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
1
|
'use strict';
|
|
30
2
|
|
|
31
3
|
const fs = require('fs');
|
|
@@ -33,16 +5,24 @@ const os = require('os');
|
|
|
33
5
|
const path = require('path');
|
|
34
6
|
const https = require('https');
|
|
35
7
|
const http = require('http');
|
|
36
|
-
const
|
|
37
|
-
const {
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const { minimumSize, prepareAssetPlan } = require('./asset-plan');
|
|
10
|
+
const { readImageMetadata, parseImageMetadata } = require('./image-metadata');
|
|
11
|
+
const { MAX_IMAGE_BYTES, uploadAttachment } = require('./attachment-upload');
|
|
12
|
+
const { getAssetStatus } = require('./asset-status');
|
|
13
|
+
|
|
14
|
+
const ALLOWED_STOCK_IMAGE_PROVIDERS = Object.freeze({
|
|
15
|
+
unsplash: Object.freeze(['images.unsplash.com']),
|
|
16
|
+
pexels: Object.freeze(['images.pexels.com']),
|
|
17
|
+
});
|
|
18
|
+
const ASSET_SOURCES = Object.freeze(['search', 'user', 'generated']);
|
|
38
19
|
|
|
39
20
|
function isHttpUrl(value) {
|
|
40
21
|
return typeof value === 'string' && /^https?:\/\//i.test(value.trim());
|
|
41
22
|
}
|
|
42
23
|
|
|
43
24
|
function isLocalFile(value) {
|
|
44
|
-
if (!value || typeof value !== 'string') {return false;}
|
|
45
|
-
if (isHttpUrl(value)) {return false;}
|
|
25
|
+
if (!value || typeof value !== 'string' || isHttpUrl(value)) {return false;}
|
|
46
26
|
try {
|
|
47
27
|
return fs.existsSync(value) && fs.statSync(value).isFile();
|
|
48
28
|
} catch {
|
|
@@ -50,250 +30,383 @@ function isLocalFile(value) {
|
|
|
50
30
|
}
|
|
51
31
|
}
|
|
52
32
|
|
|
33
|
+
function getCollectedImageProvider(rawUrl) {
|
|
34
|
+
try {
|
|
35
|
+
const host = new URL(rawUrl).hostname.toLowerCase();
|
|
36
|
+
for (const [provider, hosts] of Object.entries(ALLOWED_STOCK_IMAGE_PROVIDERS)) {
|
|
37
|
+
if (hosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`))) {return provider;}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getExternalDeliveryPolicy(rawUrl) {
|
|
46
|
+
return isHttpUrl(rawUrl) ? 'yida-attachment' : 'standard';
|
|
47
|
+
}
|
|
48
|
+
|
|
53
49
|
function extensionFromUrl(rawUrl) {
|
|
54
50
|
try {
|
|
55
|
-
const
|
|
56
|
-
const ext = path.extname(parsed.pathname || '').toLowerCase();
|
|
51
|
+
const ext = path.extname(new URL(rawUrl).pathname || '').toLowerCase();
|
|
57
52
|
return ext && ext.length <= 8 ? ext : '.img';
|
|
58
53
|
} catch {
|
|
59
54
|
return '.img';
|
|
60
55
|
}
|
|
61
56
|
}
|
|
62
57
|
|
|
63
|
-
function downloadUrlToTemp(rawUrl, options = {}) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
parsed,
|
|
77
|
-
{
|
|
78
|
-
headers: {
|
|
79
|
-
'User-Agent': 'Mozilla/5.0 (compatible; OpenYidaAssetMirror/1.0)',
|
|
80
|
-
Accept: 'image/*,*/*;q=0.8',
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
(res) => {
|
|
58
|
+
async function downloadUrlToTemp(rawUrl, options = {}, depth = 0) {
|
|
59
|
+
if (depth > 4) {throw new Error('TOO_MANY_REDIRECTS');}
|
|
60
|
+
if (options.stockOnly && !getCollectedImageProvider(rawUrl)) {throw new Error('STOCK_PROVIDER_REDIRECT_NOT_ALLOWED');}
|
|
61
|
+
const parsed = new URL(rawUrl);
|
|
62
|
+
if (!['https:', 'http:'].includes(parsed.protocol)) {throw new Error('UNSUPPORTED_PROTOCOL');}
|
|
63
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openyida-asset-'));
|
|
64
|
+
const tempPath = path.join(dir, `image${extensionFromUrl(rawUrl)}`);
|
|
65
|
+
let fileStream;
|
|
66
|
+
let fileClosed;
|
|
67
|
+
try {
|
|
68
|
+
const redirect = await new Promise((resolve, reject) => {
|
|
69
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
70
|
+
const req = client.get(parsed, { headers: { 'User-Agent': 'OpenYidaAsset/1.0', Accept: 'image/*' } }, res => {
|
|
84
71
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
85
72
|
res.resume();
|
|
86
|
-
|
|
73
|
+
try { resolve(new URL(res.headers.location, rawUrl).toString()); } catch (error) { reject(error); }
|
|
87
74
|
return;
|
|
88
75
|
}
|
|
89
|
-
if (res.statusCode < 200 || res.statusCode >=
|
|
90
|
-
res.resume();
|
|
91
|
-
reject(new Error(`HTTP_${res.statusCode || 0}`));
|
|
92
|
-
return;
|
|
76
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
77
|
+
res.resume(); reject(new Error(`HTTP_${res.statusCode}`)); return;
|
|
93
78
|
}
|
|
79
|
+
const declaredTooLarge = Number(res.headers['content-length']) > MAX_IMAGE_BYTES;
|
|
80
|
+
let bytes = 0;
|
|
81
|
+
let header = Buffer.alloc(0);
|
|
94
82
|
const out = fs.createWriteStream(tempPath);
|
|
83
|
+
fileStream = out;
|
|
84
|
+
fileClosed = new Promise(resolve => out.once('close', resolve));
|
|
85
|
+
let failed = false;
|
|
86
|
+
const fail = error => {
|
|
87
|
+
if (failed) {return;}
|
|
88
|
+
failed = true;
|
|
89
|
+
reject(error);
|
|
90
|
+
out.destroy();
|
|
91
|
+
res.destroy();
|
|
92
|
+
};
|
|
93
|
+
res.on('data', chunk => {
|
|
94
|
+
bytes += chunk.length;
|
|
95
|
+
if (header.length < 128 * 1024) {header = Buffer.concat([header, chunk.subarray(0, 128 * 1024 - header.length)]);}
|
|
96
|
+
const metadata = declaredTooLarge ? parseImageMetadata(header) : null;
|
|
97
|
+
if (bytes > MAX_IMAGE_BYTES || (declaredTooLarge && (metadata.width > 0 || header.length >= 128 * 1024))) {
|
|
98
|
+
const error = new Error('ASSET_TOO_LARGE');
|
|
99
|
+
error.metadata = metadata || parseImageMetadata(header);
|
|
100
|
+
fail(error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
res.on('error', fail);
|
|
104
|
+
res.on('aborted', () => fail(new Error('DOWNLOAD_ABORTED')));
|
|
105
|
+
out.on('error', fail);
|
|
106
|
+
out.on('finish', () => out.close(() => resolve('')));
|
|
95
107
|
res.pipe(out);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
});
|
|
109
|
+
req.setTimeout(options.timeout || 15000, () => req.destroy(new Error('TIMEOUT')));
|
|
110
|
+
req.on('error', reject);
|
|
111
|
+
});
|
|
112
|
+
if (redirect) {
|
|
113
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
114
|
+
return downloadUrlToTemp(redirect, options, depth + 1);
|
|
115
|
+
}
|
|
116
|
+
// Correct missing/wrong URL extensions using the downloaded image header.
|
|
117
|
+
const type = readImageMetadata(tempPath).type;
|
|
118
|
+
const target = path.join(dir, `image.${type || 'img'}`);
|
|
119
|
+
if (tempPath !== target) {fs.renameSync(tempPath, target);}
|
|
120
|
+
return target;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (fileStream) {
|
|
123
|
+
// destroy() closes the handle asynchronously; Windows cannot unlink it yet.
|
|
124
|
+
fileStream.destroy();
|
|
125
|
+
await fileClosed;
|
|
126
|
+
}
|
|
127
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
103
130
|
}
|
|
104
131
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
* @returns {Promise<{ resolved:boolean, url:string, source:string, reason:string }>}
|
|
110
|
-
*/
|
|
111
|
-
async function resolveOne(candidate, ctx) {
|
|
112
|
-
const raw = (candidate || '').trim();
|
|
113
|
-
if (!raw) {
|
|
114
|
-
return { resolved: false, url: '', source: 'empty', reason: 'EMPTY' };
|
|
132
|
+
function sizeFailure(metadata, ctx) {
|
|
133
|
+
if (!metadata.type) {return 'NOT_IMAGE_FILE';}
|
|
134
|
+
if (![metadata.width, metadata.height].every(value => Number.isFinite(value) && value > 0)) {
|
|
135
|
+
return 'DIMENSIONS_UNAVAILABLE';
|
|
115
136
|
}
|
|
137
|
+
if (ctx.minWidth > 0 && metadata.width < ctx.minWidth) {return 'WIDTH_TOO_SMALL';}
|
|
138
|
+
if (ctx.minHeight > 0 && metadata.height < ctx.minHeight) {return 'HEIGHT_TOO_SMALL';}
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fileHash(file) {
|
|
143
|
+
const hash = crypto.createHash('sha256');
|
|
144
|
+
const fd = fs.openSync(file, 'r');
|
|
145
|
+
try {
|
|
146
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
147
|
+
let bytes;
|
|
148
|
+
while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {hash.update(buffer.subarray(0, bytes));}
|
|
149
|
+
return hash.digest('hex');
|
|
150
|
+
} finally { fs.closeSync(fd); }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function reuseDelivery(input, metadata, ctx) {
|
|
154
|
+
const previous = ctx.previous || {};
|
|
155
|
+
const delivery = previous.delivery || {};
|
|
156
|
+
if (ctx.online === false || delivery.source !== 'yida-attachment' || delivery.input !== input ||
|
|
157
|
+
delivery.assetSource !== ctx.assetSource || !metadata.contentHash || delivery.contentHash !== metadata.contentHash ||
|
|
158
|
+
!isHttpUrl(previous.url)) {return null;}
|
|
159
|
+
return { ...metadata, resolved: true, url: previous.url, source: 'yida-attachment', reason: 'OK_REUSED' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function resolveOne(candidate, ctx = {}) {
|
|
163
|
+
const raw = String(candidate || '').trim();
|
|
164
|
+
if (!raw) {return { resolved: false, url: '', source: 'empty', reason: 'EMPTY' };}
|
|
116
165
|
|
|
117
|
-
// 外链素材:由 resolve 流程统一确认并按需镜像
|
|
118
166
|
if (isHttpUrl(raw)) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const results = await ctx.uploadFn([tempPath]);
|
|
126
|
-
const hit = (results || []).find((r) => r.success && r.originalPath === tempPath) ||
|
|
127
|
-
(results || []).find((r) => r.success);
|
|
128
|
-
if (hit && hit.cdnUrl) {
|
|
129
|
-
return { resolved: true, url: hit.cdnUrl, source: 'cdn-mirrored', reason: 'OK' };
|
|
130
|
-
}
|
|
131
|
-
const failed = (results || [])[0];
|
|
132
|
-
return {
|
|
133
|
-
resolved: false,
|
|
134
|
-
url: raw,
|
|
135
|
-
source: 'external',
|
|
136
|
-
reason: (failed && failed.error) || 'MIRROR_UPLOAD_FAILED',
|
|
137
|
-
};
|
|
138
|
-
} catch (err) {
|
|
139
|
-
return { resolved: false, url: raw, source: 'external', reason: (err && err.message) || 'MIRROR_FAILED' };
|
|
140
|
-
} finally {
|
|
141
|
-
if (tempPath) {
|
|
142
|
-
fs.rmSync(tempPath, { force: true });
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
167
|
+
if (ctx.online === false) {
|
|
168
|
+
return { resolved: false, url: raw, source: 'external', reason: 'OFFLINE_EXTERNAL_URL' };
|
|
169
|
+
}
|
|
170
|
+
const assetSource = ASSET_SOURCES.includes(ctx.assetSource) ? ctx.assetSource : 'search';
|
|
171
|
+
const inputProvider = getCollectedImageProvider(raw);
|
|
172
|
+
if (assetSource === 'search' && !inputProvider) {
|
|
146
173
|
return {
|
|
147
|
-
resolved:
|
|
148
|
-
url:
|
|
174
|
+
resolved: false,
|
|
175
|
+
url: raw,
|
|
149
176
|
source: 'external',
|
|
150
|
-
reason: '
|
|
177
|
+
reason: 'STOCK_PROVIDER_NOT_ALLOWED',
|
|
178
|
+
deliveryPolicy: 'blocked',
|
|
179
|
+
provider: null,
|
|
180
|
+
allowedProviders: Object.keys(ALLOWED_STOCK_IMAGE_PROVIDERS),
|
|
151
181
|
};
|
|
152
182
|
}
|
|
153
|
-
|
|
183
|
+
const deliveryPolicy = getExternalDeliveryPolicy(raw);
|
|
184
|
+
const common = { provider: inputProvider || ctx.provider || assetSource };
|
|
185
|
+
const original = metadata => {
|
|
186
|
+
const failure = sizeFailure(metadata, ctx);
|
|
187
|
+
if (failure) {return { ...common, resolved: false, reason: failure };}
|
|
188
|
+
return { ...common, ...metadata, imageType: metadata.type, resolved: true, url: raw,
|
|
189
|
+
source: 'external', reason: 'ASSET_TOO_LARGE', deliveryPolicy: 'original-url' };
|
|
190
|
+
};
|
|
191
|
+
let tempPath = '';
|
|
192
|
+
try {
|
|
193
|
+
tempPath = await (ctx.downloadFn || downloadUrlToTemp)(raw, { timeout: ctx.timeout, stockOnly: assetSource === 'search' });
|
|
194
|
+
if (fs.statSync(tempPath).size > MAX_IMAGE_BYTES) {return original(readImageMetadata(tempPath));}
|
|
195
|
+
const downloaded = readImageMetadata(tempPath);
|
|
196
|
+
const failure = sizeFailure(downloaded, ctx);
|
|
197
|
+
if (failure) {return { resolved: false, url: raw, source: 'external', reason: failure };}
|
|
198
|
+
Object.assign(common, downloaded, { contentHash: fileHash(tempPath), imageType: downloaded.type });
|
|
199
|
+
const reused = await reuseDelivery(raw, common, ctx);
|
|
200
|
+
if (reused) {return reused;}
|
|
201
|
+
if (!ctx.canUpload) {return { resolved: false, url: raw, source: 'external', reason: 'YIDA_ATTACHMENT_UNAVAILABLE' };}
|
|
202
|
+
const results = await ctx.uploadFn([tempPath]);
|
|
203
|
+
const hit = (results || []).find(result => result.success && result.originalPath === tempPath)
|
|
204
|
+
|| (results || []).find(result => result.success);
|
|
205
|
+
if (hit && isHttpUrl(hit.cdnUrl)) {
|
|
206
|
+
return { ...common, resolved: true, url: hit.cdnUrl, source: 'yida-attachment', reason: 'OK', deliveryPolicy };
|
|
207
|
+
}
|
|
208
|
+
return { ...common, resolved: false, url: raw, source: 'external', reason: ((results || [])[0] || {}).error || 'UPLOAD_FAILED' };
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error.message === 'ASSET_TOO_LARGE') {return original(error.metadata || {});}
|
|
211
|
+
return { ...common, resolved: false, url: raw, source: 'external', reason: error.code || error.message || 'UPLOAD_FAILED' };
|
|
212
|
+
} finally {
|
|
213
|
+
if (tempPath) {
|
|
214
|
+
fs.rmSync(tempPath, { force: true });
|
|
215
|
+
if (!ctx.downloadFn) {fs.rmSync(path.dirname(tempPath), { recursive: true, force: true });}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
154
218
|
}
|
|
155
219
|
|
|
156
|
-
// 本地文件:需要 CDN 才能转存为浏览器可访问的稳定 URL
|
|
157
220
|
if (isLocalFile(raw)) {
|
|
158
|
-
|
|
221
|
+
const assetSource = ASSET_SOURCES.includes(ctx.assetSource) ? ctx.assetSource : '';
|
|
222
|
+
const provider = String(ctx.provider || '').trim().toLowerCase();
|
|
223
|
+
if (assetSource === 'search' && !ALLOWED_STOCK_IMAGE_PROVIDERS[provider]) {
|
|
159
224
|
return {
|
|
160
225
|
resolved: false,
|
|
161
226
|
url: raw,
|
|
162
227
|
source: 'local',
|
|
163
|
-
reason: '
|
|
228
|
+
reason: 'STOCK_PROVIDER_NOT_ALLOWED',
|
|
229
|
+
provider: provider || null,
|
|
230
|
+
allowedProviders: Object.keys(ALLOWED_STOCK_IMAGE_PROVIDERS),
|
|
164
231
|
};
|
|
165
232
|
}
|
|
233
|
+
const metadata = readImageMetadata(raw);
|
|
234
|
+
const failure = sizeFailure(metadata, ctx);
|
|
235
|
+
if (failure) {
|
|
236
|
+
return { ...metadata, resolved: false, url: raw, source: 'local', reason: failure };
|
|
237
|
+
}
|
|
238
|
+
if (ctx.online === false) {
|
|
239
|
+
return { ...metadata, resolved: false, url: raw, source: 'local', reason: 'OFFLINE_UPLOAD_BLOCKED' };
|
|
240
|
+
}
|
|
241
|
+
if (fs.statSync(raw).size > MAX_IMAGE_BYTES) {return { ...metadata, resolved: false, url: '', source: 'local', reason: 'ASSET_TOO_LARGE_NO_ORIGINAL_URL' };}
|
|
242
|
+
metadata.contentHash = fileHash(raw);
|
|
243
|
+
const reused = await reuseDelivery(raw, metadata, ctx);
|
|
244
|
+
if (reused) {return reused;}
|
|
245
|
+
if (!ctx.canUpload) {
|
|
246
|
+
return { ...metadata, resolved: false, url: raw, source: 'local', reason: 'YIDA_ATTACHMENT_UNAVAILABLE' };
|
|
247
|
+
}
|
|
166
248
|
try {
|
|
167
249
|
const results = await ctx.uploadFn([raw]);
|
|
168
|
-
const hit = (results || []).find(
|
|
169
|
-
(results || []).find(
|
|
170
|
-
if (hit && hit.cdnUrl) {
|
|
171
|
-
return { resolved: true, url: hit.cdnUrl, source: '
|
|
250
|
+
const hit = (results || []).find(result => result.success && result.originalPath === raw)
|
|
251
|
+
|| (results || []).find(result => result.success);
|
|
252
|
+
if (hit && isHttpUrl(hit.cdnUrl)) {
|
|
253
|
+
return { ...metadata, resolved: true, url: hit.cdnUrl, source: 'yida-attachment', reason: 'OK' };
|
|
172
254
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
url: raw,
|
|
177
|
-
source: 'local',
|
|
178
|
-
reason: (failed && failed.error) || 'UPLOAD_FAILED',
|
|
179
|
-
};
|
|
180
|
-
} catch (err) {
|
|
181
|
-
return { resolved: false, url: raw, source: 'local', reason: (err && err.message) || 'UPLOAD_ERROR' };
|
|
255
|
+
return { ...metadata, resolved: false, url: raw, source: 'local', reason: ((results || [])[0] || {}).error || 'UPLOAD_FAILED' };
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return { ...metadata, resolved: false, url: raw, source: 'local', reason: error.code || error.message || 'UPLOAD_FAILED' };
|
|
182
258
|
}
|
|
183
259
|
}
|
|
184
260
|
|
|
185
|
-
// 既不是外链也不是磁盘文件 → 视为未落地的占位引用
|
|
186
261
|
return { resolved: false, url: raw, source: 'unknown', reason: 'NOT_FOUND' };
|
|
187
262
|
}
|
|
188
263
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
* @param {object} [options]
|
|
193
|
-
* @param {boolean} [options.requireHero] 页面是否必须有 Hero 图(官网/落地页通常 true)
|
|
194
|
-
* @param {boolean} [options.online] 是否可联网处理外链(默认 true)
|
|
195
|
-
* @param {number} [options.timeout] 单个外链处理超时
|
|
196
|
-
* @param {function} [options.uploadFn] 自定义上传函数(默认懒加载 cdn-upload.performUpload)
|
|
197
|
-
* @returns {Promise<{
|
|
198
|
-
* assets: object,
|
|
199
|
-
* materialStatus: 'final'|'draft'|'none',
|
|
200
|
-
* materialGaps: string[],
|
|
201
|
-
* strategy: string,
|
|
202
|
-
* actions: Array,
|
|
203
|
-
* status: object
|
|
204
|
-
* }>}
|
|
205
|
-
*/
|
|
206
|
-
async function resolveAssets(assets = {}, options = {}) {
|
|
207
|
-
const online = options.online !== false;
|
|
208
|
-
const requireHero = !!options.requireHero;
|
|
209
|
-
const status = getAssetStatus({ online });
|
|
210
|
-
const hasInjectedUpload = typeof options.uploadFn === 'function';
|
|
211
|
-
|
|
212
|
-
const ctx = {
|
|
213
|
-
canUpload: status.canUpload || hasInjectedUpload,
|
|
214
|
-
online,
|
|
215
|
-
mirrorExternal: !!options.mirrorExternal,
|
|
216
|
-
timeout: options.timeout,
|
|
217
|
-
verifyFn: options.verifyFn,
|
|
218
|
-
downloadFn: options.downloadFn,
|
|
219
|
-
uploadFn:
|
|
220
|
-
options.uploadFn ||
|
|
221
|
-
((files) => {
|
|
222
|
-
// 懒加载,避免无 CDN 场景加载 ali-oss
|
|
223
|
-
const { performUpload } = require('../cdn/cdn-upload');
|
|
224
|
-
return performUpload({ files });
|
|
225
|
-
}),
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
const inputHero = assets.heroImage || '';
|
|
229
|
-
const inputProducts = Array.isArray(assets.productImages) ? assets.productImages : [];
|
|
230
|
-
|
|
231
|
-
const actions = [];
|
|
232
|
-
const materialGaps = [];
|
|
233
|
-
|
|
234
|
-
// Hero
|
|
235
|
-
let heroUrl = '';
|
|
236
|
-
if (inputHero) {
|
|
237
|
-
const r = await resolveOne(inputHero, ctx);
|
|
238
|
-
actions.push({ slot: 'heroImage', input: inputHero, ...r });
|
|
239
|
-
if (r.resolved) {
|
|
240
|
-
heroUrl = r.url;
|
|
241
|
-
} else {
|
|
242
|
-
materialGaps.push(`Hero 图未落地(${r.reason}):${inputHero}`);
|
|
243
|
-
}
|
|
244
|
-
} else if (requireHero) {
|
|
245
|
-
materialGaps.push('缺少 Hero 图:官网/落地页应提供一张品牌主视觉大图。');
|
|
246
|
-
}
|
|
264
|
+
function normalizeSource(source, fallback) {
|
|
265
|
+
return ASSET_SOURCES.includes(source) ? source : fallback;
|
|
266
|
+
}
|
|
247
267
|
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
268
|
+
function metadataGaps(asset) {
|
|
269
|
+
const missing = [];
|
|
270
|
+
['slotId', 'usage', 'alt'].forEach((field) => {
|
|
271
|
+
if (typeof asset[field] !== 'string' || !asset[field].trim()) {missing.push(field);}
|
|
272
|
+
});
|
|
273
|
+
if (![asset.width, asset.height].every(value => Number.isFinite(value) && value > 0)) {missing.push('width/height');}
|
|
274
|
+
if (asset.source === 'search') {
|
|
275
|
+
['provider', 'sourcePage', 'creator', 'license', 'attribution'].forEach((field) => {
|
|
276
|
+
if (typeof asset[field] !== 'string' || !asset[field].trim()) {missing.push(field);}
|
|
277
|
+
});
|
|
278
|
+
if (asset.provider === 'unsplash') {
|
|
279
|
+
if (!asset.downloadLocation) {missing.push('downloadLocation');}
|
|
280
|
+
if (asset.downloadTracked !== true) {missing.push('downloadTracked');}
|
|
258
281
|
}
|
|
259
282
|
}
|
|
283
|
+
return missing;
|
|
284
|
+
}
|
|
260
285
|
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
materialStatus = 'draft';
|
|
270
|
-
} else if (requireHero) {
|
|
271
|
-
// 需要图片却一张都没落地 → 低保真
|
|
272
|
-
materialStatus = 'none';
|
|
273
|
-
} else {
|
|
274
|
-
materialStatus = 'draft';
|
|
275
|
-
}
|
|
286
|
+
async function resolveAssets(assets = [], options = {}) {
|
|
287
|
+
const plan = prepareAssetPlan(assets, options.assetStrategy);
|
|
288
|
+
const entries = plan.entries;
|
|
289
|
+
const online = options.online !== false;
|
|
290
|
+
const status = options.status || getAssetStatus({ online, env: options.env, runtime: options.runtime });
|
|
291
|
+
const canUpload = online;
|
|
292
|
+
const defaultSource = normalizeSource(options.assetSource, 'search');
|
|
293
|
+
const uploadFn = options.uploadFn || (files => uploadAttachment(files, options));
|
|
276
294
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
295
|
+
const actions = await Promise.all(entries.map(async (asset, index) => {
|
|
296
|
+
const declaredSource = String(asset.source || '').trim();
|
|
297
|
+
const source = declaredSource || defaultSource;
|
|
298
|
+
const input = String(asset.input || asset.localPath || asset.url || '').trim();
|
|
299
|
+
const size = minimumSize(asset);
|
|
300
|
+
if (!ASSET_SOURCES.includes(source) || (source === 'generated' && asset.generationAllowed === false)) {
|
|
301
|
+
return {
|
|
302
|
+
index,
|
|
303
|
+
asset,
|
|
304
|
+
input,
|
|
305
|
+
source,
|
|
306
|
+
result: { resolved: false, url: input, source: 'unknown', reason: !ASSET_SOURCES.includes(source) ? 'INVALID_SOURCE' : 'GENERATION_NOT_ALLOWED' },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
let result;
|
|
310
|
+
try {
|
|
311
|
+
result = await resolveOne(input, {
|
|
312
|
+
previous: asset,
|
|
313
|
+
assetSource: source,
|
|
314
|
+
provider: asset.provider,
|
|
315
|
+
online,
|
|
316
|
+
canUpload,
|
|
317
|
+
timeout: options.timeout,
|
|
318
|
+
downloadFn: options.downloadFn,
|
|
319
|
+
uploadFn,
|
|
320
|
+
...size,
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
result = { resolved: false, url: input, reason: error.code || 'ASSET_READ_FAILED' };
|
|
324
|
+
}
|
|
325
|
+
return { index, asset, input, source, result };
|
|
326
|
+
}));
|
|
282
327
|
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
328
|
+
const outputAssets = [];
|
|
329
|
+
const gaps = [];
|
|
330
|
+
actions.forEach(({ asset, input, source, result }, index) => {
|
|
331
|
+
const slotId = String(asset.slotId || '').trim();
|
|
332
|
+
const provider = result.provider || asset.provider || '';
|
|
333
|
+
const output = {
|
|
334
|
+
slotId,
|
|
335
|
+
pageId: asset.pageId || '',
|
|
336
|
+
input,
|
|
337
|
+
...minimumSize(asset),
|
|
338
|
+
...(asset.minSize !== undefined ? { minSize: asset.minSize } : {}),
|
|
339
|
+
usage: asset.usage || '',
|
|
340
|
+
required: asset.required !== false,
|
|
341
|
+
url: result.resolved ? result.url : '',
|
|
342
|
+
localPath: isLocalFile(input) ? input : '',
|
|
343
|
+
source,
|
|
344
|
+
provider,
|
|
345
|
+
sourcePage: asset.sourcePage || '',
|
|
346
|
+
creator: asset.creator || '',
|
|
347
|
+
license: asset.license || '',
|
|
348
|
+
attribution: asset.attribution || '',
|
|
349
|
+
downloadLocation: asset.downloadLocation || '',
|
|
350
|
+
downloadTracked: asset.downloadTracked === true,
|
|
351
|
+
width: result.width || 0,
|
|
352
|
+
height: result.height || 0,
|
|
353
|
+
imageType: result.imageType || result.type || '',
|
|
354
|
+
...(result.resolved ? { delivery: { source: result.source, input, assetSource: source, contentHash: result.contentHash || '', reason: result.reason } } : {}),
|
|
355
|
+
alt: asset.alt || '',
|
|
356
|
+
isIllustrative: asset.isIllustrative === true || source === 'generated',
|
|
357
|
+
};
|
|
358
|
+
output.materialStatus = 'draft';
|
|
359
|
+
output.gaps = [];
|
|
360
|
+
outputAssets.push(output);
|
|
361
|
+
if (!result.resolved) {
|
|
362
|
+
output.gaps.push({
|
|
363
|
+
slotId: slotId || `slot-${index + 1}`,
|
|
364
|
+
pageId: output.pageId,
|
|
365
|
+
code: result.reason,
|
|
366
|
+
message: `图片未落地:${result.reason}`,
|
|
367
|
+
});
|
|
368
|
+
gaps.push(...output.gaps.map(gap => ({ ...gap })));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const missing = metadataGaps(output);
|
|
372
|
+
if (missing.length > 0) {
|
|
373
|
+
output.gaps.push({
|
|
374
|
+
slotId: slotId || `slot-${index + 1}`,
|
|
375
|
+
pageId: output.pageId,
|
|
376
|
+
code: 'MISSING_METADATA',
|
|
377
|
+
message: `缺少字段:${missing.join(', ')}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
output.materialStatus = output.gaps.length ? 'draft' : 'final';
|
|
381
|
+
gaps.push(...output.gaps.map(gap => ({ ...gap })));
|
|
382
|
+
});
|
|
289
383
|
|
|
384
|
+
const pages = plan.pages.map(page => {
|
|
385
|
+
const pageAssets = outputAssets.filter(asset => asset.pageId === page.pageId);
|
|
386
|
+
const pageGaps = gaps.filter(gap => gap.pageId === page.pageId).map(gap => ({ ...gap }));
|
|
387
|
+
if (page.imageNeed === 'required' && page.slotIds.length === 0) {
|
|
388
|
+
const gap = { pageId: page.pageId, code: 'MISSING_PAGE_SLOTS', message: 'MISSING_PAGE_SLOTS' };
|
|
389
|
+
pageGaps.push(gap);
|
|
390
|
+
gaps.push({ ...gap });
|
|
391
|
+
}
|
|
392
|
+
const blocked = pageGaps.some(gap => !gap.slotId || pageAssets.some(asset => asset.slotId === gap.slotId && asset.required));
|
|
393
|
+
return { ...page, materialStatus: blocked ? 'draft' : pageAssets.length ? 'final' : 'none', gaps: pageGaps };
|
|
394
|
+
});
|
|
395
|
+
const materialStatus = gaps.length ? 'draft' : entries.length ? 'final' : 'none';
|
|
290
396
|
return {
|
|
291
|
-
|
|
397
|
+
schemaVersion: 2,
|
|
292
398
|
materialStatus,
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
399
|
+
...(plan.assetStrategy !== undefined ? { assetStrategy: plan.assetStrategy } : {}),
|
|
400
|
+
pages,
|
|
401
|
+
capabilityEvidence: {
|
|
402
|
+
host: status.hostCapabilities,
|
|
403
|
+
cdnConfigured: status.cdnConfigured,
|
|
404
|
+
uploadTarget: 'yida-attachment',
|
|
405
|
+
maxUploadBytes: MAX_IMAGE_BYTES,
|
|
406
|
+
online,
|
|
407
|
+
},
|
|
408
|
+
assets: outputAssets,
|
|
409
|
+
gaps,
|
|
297
410
|
};
|
|
298
411
|
}
|
|
299
412
|
|
|
@@ -302,4 +415,9 @@ module.exports = {
|
|
|
302
415
|
resolveOne,
|
|
303
416
|
isHttpUrl,
|
|
304
417
|
isLocalFile,
|
|
418
|
+
getExternalDeliveryPolicy,
|
|
419
|
+
getCollectedImageProvider,
|
|
420
|
+
ALLOWED_STOCK_IMAGE_PROVIDERS,
|
|
421
|
+
ASSET_SOURCES,
|
|
422
|
+
downloadUrlToTemp,
|
|
305
423
|
};
|