@things-factory/attachment-base 10.0.0-beta.7 → 10.0.0-beta.71
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/dist-server/service/attachment/attachment-mutation.d.ts +2 -0
- package/dist-server/service/attachment/attachment-mutation.js +54 -13
- package/dist-server/service/attachment/attachment-mutation.js.map +1 -1
- package/dist-server/service/attachment/attachment.d.ts +7 -0
- package/dist-server/service/attachment/attachment.js +13 -1
- package/dist-server/service/attachment/attachment.js.map +1 -1
- package/dist-server/service/attachment/gltf-thumbnail.d.ts +6 -0
- package/dist-server/service/attachment/gltf-thumbnail.js +208 -0
- package/dist-server/service/attachment/gltf-thumbnail.js.map +1 -0
- package/dist-server/service/attachment/mimetype.d.ts +47 -0
- package/dist-server/service/attachment/mimetype.js +111 -0
- package/dist-server/service/attachment/mimetype.js.map +1 -0
- package/dist-server/service/attachment/thumbnail-backfill.d.ts +17 -0
- package/dist-server/service/attachment/thumbnail-backfill.js +115 -0
- package/dist-server/service/attachment/thumbnail-backfill.js.map +1 -0
- package/dist-server/service/attachment/thumbnail.d.ts +13 -0
- package/dist-server/service/attachment/thumbnail.js +47 -0
- package/dist-server/service/attachment/thumbnail.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -6
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 업로드 파일의 mimetype 정규화.
|
|
4
|
+
*
|
|
5
|
+
* Apollo `graphql-upload` 의 FileUpload.mimetype 은 클라이언트가 보낸
|
|
6
|
+
* multipart/form-data 의 Content-Type 을 그대로 받는다. 이 값은 OS / 브라우저의
|
|
7
|
+
* MIME 데이터베이스가 확장자를 인식하느냐에 따라 같은 파일이라도 다르게 들어온다.
|
|
8
|
+
* 대표적으로 GLB:
|
|
9
|
+
* - .glb 가 MIME DB 에 등록된 환경 → 'model/gltf-binary'
|
|
10
|
+
* - 미등록 환경 (Windows 일부 등) → 'application/octet-stream'
|
|
11
|
+
* - zip 해제 / 메일 첨부 거치면서 손실 → 'application/octet-stream'
|
|
12
|
+
*
|
|
13
|
+
* 이 불일치 때문에 썸네일 생성, 검색, category 분류 등 후속 로직이 같은 파일에
|
|
14
|
+
* 대해 다르게 동작했다. 본 모듈은 모호한 mime ('' / 'application/octet-stream')
|
|
15
|
+
* 일 때만 magic byte → 확장자 순으로 재판정하고, 의미 있는 mime 은 그대로 둔다.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.normalizeMimetype = normalizeMimetype;
|
|
19
|
+
exports.detectMimetypeFromContent = detectMimetypeFromContent;
|
|
20
|
+
exports.categoryFromMimetype = categoryFromMimetype;
|
|
21
|
+
const GENERIC_BINARY_MIMETYPES = new Set(['', 'application/octet-stream', 'binary/octet-stream']);
|
|
22
|
+
/**
|
|
23
|
+
* 파일 컨텐츠의 처음 몇 바이트(magic byte)로 알려진 포맷을 식별한다.
|
|
24
|
+
* 알 수 없으면 undefined 반환. 의도적으로 좁은 집합만 식별 — 잘못된 추론을
|
|
25
|
+
* 피하기 위해 의심스러운 케이스는 호출자가 다음 단서(확장자)로 넘긴다.
|
|
26
|
+
*/
|
|
27
|
+
function detectByMagic(contents) {
|
|
28
|
+
if (!contents || contents.length < 4)
|
|
29
|
+
return;
|
|
30
|
+
// GLB: ASCII 'glTF'
|
|
31
|
+
if (contents[0] === 0x67 && contents[1] === 0x6c && contents[2] === 0x54 && contents[3] === 0x46) {
|
|
32
|
+
return 'model/gltf-binary';
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 파일명 확장자로 알려진 포맷을 추론.
|
|
38
|
+
*/
|
|
39
|
+
function detectByExtension(filename) {
|
|
40
|
+
const lower = filename.toLowerCase();
|
|
41
|
+
if (lower.endsWith('.glb'))
|
|
42
|
+
return 'model/gltf-binary';
|
|
43
|
+
if (lower.endsWith('.gltf'))
|
|
44
|
+
return 'model/gltf+json';
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 모호한 원본 mimetype 을 컨텐츠/파일명 기반으로 재판정한다.
|
|
49
|
+
*
|
|
50
|
+
* 정규화 규칙:
|
|
51
|
+
* 1. 원본 mime 이 의미 있으면 (예: 'image/png', 'model/gltf-binary') 그대로 보존.
|
|
52
|
+
* 2. 원본 mime 이 모호한 경우 (`''`, `application/octet-stream`,
|
|
53
|
+
* `binary/octet-stream`) magic byte → 확장자 순으로 추론.
|
|
54
|
+
* 3. 추론도 실패하면 원본 그대로 반환 (정보 손실 없음).
|
|
55
|
+
*
|
|
56
|
+
* 이 함수는 동일 파일이 환경 차이로 다른 mime 으로 들어와도 일관된 mime 으로
|
|
57
|
+
* 저장/처리되도록 보장한다 — 썸네일 생성, 카테고리 분류, backfill 쿼리 등
|
|
58
|
+
* mime 을 키로 하는 후속 로직 모두에 영향.
|
|
59
|
+
*/
|
|
60
|
+
function normalizeMimetype(originalMime, filename, contents) {
|
|
61
|
+
const mime = originalMime || '';
|
|
62
|
+
if (!GENERIC_BINARY_MIMETYPES.has(mime))
|
|
63
|
+
return mime;
|
|
64
|
+
if (contents) {
|
|
65
|
+
const detected = detectByMagic(contents);
|
|
66
|
+
if (detected)
|
|
67
|
+
return detected;
|
|
68
|
+
}
|
|
69
|
+
if (filename) {
|
|
70
|
+
const detected = detectByExtension(filename);
|
|
71
|
+
if (detected)
|
|
72
|
+
return detected;
|
|
73
|
+
}
|
|
74
|
+
return mime;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 외부에서 (예: 백필 쿼리) 모호한 mime 으로 저장된 첨부파일의 실제 포맷을 추정.
|
|
78
|
+
* normalizeMimetype 와 동일한 매직바이트/확장자 결과를 반환하지만, 의미 있는
|
|
79
|
+
* 원본 mime 보존 정책을 거치지 않는다 — 이미 저장된 데이터의 실제 포맷이
|
|
80
|
+
* 무엇인지를 알아내야 하는 backfill 시나리오 전용.
|
|
81
|
+
*/
|
|
82
|
+
function detectMimetypeFromContent(filename, contents) {
|
|
83
|
+
if (contents) {
|
|
84
|
+
const detected = detectByMagic(contents);
|
|
85
|
+
if (detected)
|
|
86
|
+
return detected;
|
|
87
|
+
}
|
|
88
|
+
if (filename) {
|
|
89
|
+
const detected = detectByExtension(filename);
|
|
90
|
+
if (detected)
|
|
91
|
+
return detected;
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* mimetype 으로부터 attachment.category 결정.
|
|
97
|
+
*
|
|
98
|
+
* 기본 규칙은 mimetype 의 main type ('image/png' → 'image'). 단, 3D 모델
|
|
99
|
+
* (model/*) 은 시각 자산이라 사용자 인지 측면에서 'image' 카테고리로 묶는다.
|
|
100
|
+
* 별도의 'model' 카테고리를 두지 않은 이유는 기존 attachment picker UI 들이
|
|
101
|
+
* 'image' 카테고리를 기준으로 시각 자산을 필터링하기 때문 — picker 마다 옵션
|
|
102
|
+
* 추가 작업 부담을 피하기 위한 실용적 선택. 향후 3D 모델 전용 picker 가
|
|
103
|
+
* 필요해지면 'model' 분리를 재검토.
|
|
104
|
+
*/
|
|
105
|
+
function categoryFromMimetype(mimetype) {
|
|
106
|
+
const main = (mimetype || '').split('/').shift() || '';
|
|
107
|
+
if (main === 'model')
|
|
108
|
+
return 'image';
|
|
109
|
+
return main;
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=mimetype.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mimetype.js","sourceRoot":"","sources":["../../../server/service/attachment/mimetype.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;GAcG;;AA6CH,8CAoBC;AAQD,8DAeC;AAYD,oDAIC;AAtGD,MAAM,wBAAwB,GAAG,IAAI,GAAG,CAAC,CAAC,EAAE,EAAE,0BAA0B,EAAE,qBAAqB,CAAC,CAAC,CAAA;AAEjG;;;;GAIG;AACH,SAAS,aAAa,CAAC,QAAgB;IACrC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,OAAM;IAE5C,oBAAoB;IACpB,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QACjG,OAAO,mBAAmB,CAAA;IAC5B,CAAC;IAED,OAAM;AACR,CAAC;AAED;;GAEG;AACH,SAAS,iBAAiB,CAAC,QAAgB;IACzC,MAAM,KAAK,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAA;IAEpC,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAO,mBAAmB,CAAA;IACtD,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,OAAO,iBAAiB,CAAA;IAErD,OAAM;AACR,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAgB,iBAAiB,CAC/B,YAAgC,EAChC,QAA4B,EAC5B,QAAmC;IAEnC,MAAM,IAAI,GAAG,YAAY,IAAI,EAAE,CAAA;IAE/B,IAAI,CAAC,wBAAwB,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAEpD,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,QAAQ,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAA;QACxC,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAA;IAC/B,CAAC;IAED,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,QAAQ,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAA;QAC5C,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAA;IAC/B,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;GAKG;AACH,SAAgB,yBAAyB,CACvC,QAA4B,EAC5B,QAAmC;IAEnC,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,QAAQ,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAA;QACxC,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAA;IAC/B,CAAC;IAED,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,QAAQ,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAA;QAC5C,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAA;IAC/B,CAAC;IAED,OAAM;AACR,CAAC;AAED;;;;;;;;;GASG;AACH,SAAgB,oBAAoB,CAAC,QAA4B;IAC/D,MAAM,IAAI,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,CAAA;IACtD,IAAI,IAAI,KAAK,OAAO;QAAE,OAAO,OAAO,CAAA;IACpC,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["/**\n * 업로드 파일의 mimetype 정규화.\n *\n * Apollo `graphql-upload` 의 FileUpload.mimetype 은 클라이언트가 보낸\n * multipart/form-data 의 Content-Type 을 그대로 받는다. 이 값은 OS / 브라우저의\n * MIME 데이터베이스가 확장자를 인식하느냐에 따라 같은 파일이라도 다르게 들어온다.\n * 대표적으로 GLB:\n * - .glb 가 MIME DB 에 등록된 환경 → 'model/gltf-binary'\n * - 미등록 환경 (Windows 일부 등) → 'application/octet-stream'\n * - zip 해제 / 메일 첨부 거치면서 손실 → 'application/octet-stream'\n *\n * 이 불일치 때문에 썸네일 생성, 검색, category 분류 등 후속 로직이 같은 파일에\n * 대해 다르게 동작했다. 본 모듈은 모호한 mime ('' / 'application/octet-stream')\n * 일 때만 magic byte → 확장자 순으로 재판정하고, 의미 있는 mime 은 그대로 둔다.\n */\n\nconst GENERIC_BINARY_MIMETYPES = new Set(['', 'application/octet-stream', 'binary/octet-stream'])\n\n/**\n * 파일 컨텐츠의 처음 몇 바이트(magic byte)로 알려진 포맷을 식별한다.\n * 알 수 없으면 undefined 반환. 의도적으로 좁은 집합만 식별 — 잘못된 추론을\n * 피하기 위해 의심스러운 케이스는 호출자가 다음 단서(확장자)로 넘긴다.\n */\nfunction detectByMagic(contents: Buffer): string | undefined {\n if (!contents || contents.length < 4) return\n\n // GLB: ASCII 'glTF'\n if (contents[0] === 0x67 && contents[1] === 0x6c && contents[2] === 0x54 && contents[3] === 0x46) {\n return 'model/gltf-binary'\n }\n\n return\n}\n\n/**\n * 파일명 확장자로 알려진 포맷을 추론.\n */\nfunction detectByExtension(filename: string): string | undefined {\n const lower = filename.toLowerCase()\n\n if (lower.endsWith('.glb')) return 'model/gltf-binary'\n if (lower.endsWith('.gltf')) return 'model/gltf+json'\n\n return\n}\n\n/**\n * 모호한 원본 mimetype 을 컨텐츠/파일명 기반으로 재판정한다.\n *\n * 정규화 규칙:\n * 1. 원본 mime 이 의미 있으면 (예: 'image/png', 'model/gltf-binary') 그대로 보존.\n * 2. 원본 mime 이 모호한 경우 (`''`, `application/octet-stream`,\n * `binary/octet-stream`) magic byte → 확장자 순으로 추론.\n * 3. 추론도 실패하면 원본 그대로 반환 (정보 손실 없음).\n *\n * 이 함수는 동일 파일이 환경 차이로 다른 mime 으로 들어와도 일관된 mime 으로\n * 저장/처리되도록 보장한다 — 썸네일 생성, 카테고리 분류, backfill 쿼리 등\n * mime 을 키로 하는 후속 로직 모두에 영향.\n */\nexport function normalizeMimetype(\n originalMime: string | undefined,\n filename: string | undefined,\n contents: Buffer | undefined | null\n): string {\n const mime = originalMime || ''\n\n if (!GENERIC_BINARY_MIMETYPES.has(mime)) return mime\n\n if (contents) {\n const detected = detectByMagic(contents)\n if (detected) return detected\n }\n\n if (filename) {\n const detected = detectByExtension(filename)\n if (detected) return detected\n }\n\n return mime\n}\n\n/**\n * 외부에서 (예: 백필 쿼리) 모호한 mime 으로 저장된 첨부파일의 실제 포맷을 추정.\n * normalizeMimetype 와 동일한 매직바이트/확장자 결과를 반환하지만, 의미 있는\n * 원본 mime 보존 정책을 거치지 않는다 — 이미 저장된 데이터의 실제 포맷이\n * 무엇인지를 알아내야 하는 backfill 시나리오 전용.\n */\nexport function detectMimetypeFromContent(\n filename: string | undefined,\n contents: Buffer | undefined | null\n): string | undefined {\n if (contents) {\n const detected = detectByMagic(contents)\n if (detected) return detected\n }\n\n if (filename) {\n const detected = detectByExtension(filename)\n if (detected) return detected\n }\n\n return\n}\n\n/**\n * mimetype 으로부터 attachment.category 결정.\n *\n * 기본 규칙은 mimetype 의 main type ('image/png' → 'image'). 단, 3D 모델\n * (model/*) 은 시각 자산이라 사용자 인지 측면에서 'image' 카테고리로 묶는다.\n * 별도의 'model' 카테고리를 두지 않은 이유는 기존 attachment picker UI 들이\n * 'image' 카테고리를 기준으로 시각 자산을 필터링하기 때문 — picker 마다 옵션\n * 추가 작업 부담을 피하기 위한 실용적 선택. 향후 3D 모델 전용 picker 가\n * 필요해지면 'model' 분리를 재검토.\n */\nexport function categoryFromMimetype(mimetype: string | undefined): string {\n const main = (mimetype || '').split('/').shift() || ''\n if (main === 'model') return 'image'\n return main\n}\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class ThumbnailBackfillResult {
|
|
2
|
+
attempted: number;
|
|
3
|
+
succeeded: number;
|
|
4
|
+
failed: number;
|
|
5
|
+
remaining: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 썸네일이 없는 첨부파일을 조회해서 서버에서 썸네일을 생성·저장한다.
|
|
9
|
+
* 하나의 호출당 최대 `limit` 개까지만 처리 — 장시간 점유 방지.
|
|
10
|
+
* 결과의 `remaining > 0` 이면 동일 호출을 반복해 배치 완료시킴.
|
|
11
|
+
*
|
|
12
|
+
* 대상 mimetype 만 시도 (현재 image/*, model/gltf-*).
|
|
13
|
+
* contents 컬럼이 비어 있고 STORAGE.readFile 로도 못 읽으면 skip.
|
|
14
|
+
*/
|
|
15
|
+
export declare function backfillAttachmentThumbnails({ limit }: {
|
|
16
|
+
limit?: number;
|
|
17
|
+
}, context: ResolverContext): Promise<ThumbnailBackfillResult>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ThumbnailBackfillResult = void 0;
|
|
4
|
+
exports.backfillAttachmentThumbnails = backfillAttachmentThumbnails;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const type_graphql_1 = require("type-graphql");
|
|
7
|
+
const env_1 = require("@things-factory/env");
|
|
8
|
+
const shell_1 = require("@things-factory/shell");
|
|
9
|
+
const attachment_const_1 = require("../../attachment-const");
|
|
10
|
+
const attachment_1 = require("./attachment");
|
|
11
|
+
const thumbnail_1 = require("./thumbnail");
|
|
12
|
+
let ThumbnailBackfillResult = class ThumbnailBackfillResult {
|
|
13
|
+
};
|
|
14
|
+
exports.ThumbnailBackfillResult = ThumbnailBackfillResult;
|
|
15
|
+
tslib_1.__decorate([
|
|
16
|
+
(0, type_graphql_1.Field)(() => type_graphql_1.Int, { description: '이번 호출에서 처리 시도한 첨부 개수' }),
|
|
17
|
+
tslib_1.__metadata("design:type", Number)
|
|
18
|
+
], ThumbnailBackfillResult.prototype, "attempted", void 0);
|
|
19
|
+
tslib_1.__decorate([
|
|
20
|
+
(0, type_graphql_1.Field)(() => type_graphql_1.Int, { description: '썸네일 생성·저장 성공 개수' }),
|
|
21
|
+
tslib_1.__metadata("design:type", Number)
|
|
22
|
+
], ThumbnailBackfillResult.prototype, "succeeded", void 0);
|
|
23
|
+
tslib_1.__decorate([
|
|
24
|
+
(0, type_graphql_1.Field)(() => type_graphql_1.Int, { description: '실패(생성 실패/콘텐츠 없음 등) 개수' }),
|
|
25
|
+
tslib_1.__metadata("design:type", Number)
|
|
26
|
+
], ThumbnailBackfillResult.prototype, "failed", void 0);
|
|
27
|
+
tslib_1.__decorate([
|
|
28
|
+
(0, type_graphql_1.Field)(() => type_graphql_1.Int, {
|
|
29
|
+
description: '이번 처리 후에도 남아있는 썸네일 미생성 후보 개수 (대략치). 0 이면 완료'
|
|
30
|
+
}),
|
|
31
|
+
tslib_1.__metadata("design:type", Number)
|
|
32
|
+
], ThumbnailBackfillResult.prototype, "remaining", void 0);
|
|
33
|
+
exports.ThumbnailBackfillResult = ThumbnailBackfillResult = tslib_1.__decorate([
|
|
34
|
+
(0, type_graphql_1.ObjectType)({ description: '썸네일 백필 결과' })
|
|
35
|
+
], ThumbnailBackfillResult);
|
|
36
|
+
/**
|
|
37
|
+
* 썸네일이 없는 첨부파일을 조회해서 서버에서 썸네일을 생성·저장한다.
|
|
38
|
+
* 하나의 호출당 최대 `limit` 개까지만 처리 — 장시간 점유 방지.
|
|
39
|
+
* 결과의 `remaining > 0` 이면 동일 호출을 반복해 배치 완료시킴.
|
|
40
|
+
*
|
|
41
|
+
* 대상 mimetype 만 시도 (현재 image/*, model/gltf-*).
|
|
42
|
+
* contents 컬럼이 비어 있고 STORAGE.readFile 로도 못 읽으면 skip.
|
|
43
|
+
*/
|
|
44
|
+
async function backfillAttachmentThumbnails({ limit = 20 }, context) {
|
|
45
|
+
// ResolverContext 은 things-factory 런타임 전역 타입
|
|
46
|
+
const { domain, tx } = context.state;
|
|
47
|
+
const repository = tx ? tx.getRepository(attachment_1.Attachment) : (0, shell_1.getRepository)(attachment_1.Attachment);
|
|
48
|
+
// 지원 mimetype 중 썸네일 미생성 대상만 조회
|
|
49
|
+
const qb = repository.createQueryBuilder('att')
|
|
50
|
+
.where('att.domain_id = :domainId', { domainId: domain.id })
|
|
51
|
+
.andWhere('att.thumbnail IS NULL')
|
|
52
|
+
.andWhere('(att.mimetype LIKE :imagePrefix OR att.mimetype IN (:...gltfTypes))', {
|
|
53
|
+
imagePrefix: 'image/%',
|
|
54
|
+
gltfTypes: ['model/gltf-binary', 'model/gltf+json']
|
|
55
|
+
});
|
|
56
|
+
const candidates = await qb.take(limit).getMany();
|
|
57
|
+
let succeeded = 0;
|
|
58
|
+
let failed = 0;
|
|
59
|
+
for (const att of candidates) {
|
|
60
|
+
try {
|
|
61
|
+
const contents = await resolveContents(att);
|
|
62
|
+
if (!contents) {
|
|
63
|
+
failed++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const thumbnail = await (0, thumbnail_1.generateThumbnail)(contents, att.mimetype);
|
|
67
|
+
if (!thumbnail) {
|
|
68
|
+
failed++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
await repository.update({ id: att.id }, { thumbnail });
|
|
72
|
+
succeeded++;
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
failed++;
|
|
76
|
+
env_1.logger.warn(`[thumbnail-backfill] ${att.id} failed: ${e.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 남은 후보 개수 — 다음 호출 필요 여부 판단용
|
|
80
|
+
const remaining = await qb.getCount();
|
|
81
|
+
return {
|
|
82
|
+
attempted: candidates.length,
|
|
83
|
+
succeeded,
|
|
84
|
+
failed,
|
|
85
|
+
remaining
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* contents 획득: DB 저장 모드는 att.contents Buffer 직접, 파일/S3/Azure 모드는
|
|
90
|
+
* STORAGE.readFile 로 경로에서 읽는다. 읽기 실패 시 undefined.
|
|
91
|
+
*/
|
|
92
|
+
async function resolveContents(att) {
|
|
93
|
+
// @ts-ignore — TypeORM 이 contents 를 Buffer 로 반환 (타입 시그니처는 any)
|
|
94
|
+
const fromDb = att.contents;
|
|
95
|
+
if (fromDb && fromDb.length > 0)
|
|
96
|
+
return fromDb;
|
|
97
|
+
if (!att.path)
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
const readFile = attachment_const_1.STORAGE.readFile;
|
|
101
|
+
if (typeof readFile !== 'function')
|
|
102
|
+
return;
|
|
103
|
+
const result = await readFile(att.path);
|
|
104
|
+
if (typeof result === 'string')
|
|
105
|
+
return Buffer.from(result);
|
|
106
|
+
if (Buffer.isBuffer(result))
|
|
107
|
+
return result;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
env_1.logger.warn(`[thumbnail-backfill] readFile failed for ${att.path}: ${e.message}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=thumbnail-backfill.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thumbnail-backfill.js","sourceRoot":"","sources":["../../../server/service/attachment/thumbnail-backfill.ts"],"names":[],"mappings":";;;AAmCA,oEAqDC;;AAxFD,+CAAqD;AAGrD,6CAA4C;AAC5C,iDAAqD;AAErD,6DAAgD;AAChD,6CAAyC;AACzC,2CAA+C;AAGxC,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;CAcnC,CAAA;AAdY,0DAAuB;AAElC;IADC,IAAA,oBAAK,EAAC,GAAG,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,sBAAsB,EAAE,CAAC;;0DACxC;AAGlB;IADC,IAAA,oBAAK,EAAC,GAAG,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,iBAAiB,EAAE,CAAC;;0DACnC;AAGlB;IADC,IAAA,oBAAK,EAAC,GAAG,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC;;uDAC5C;AAKf;IAHC,IAAA,oBAAK,EAAC,GAAG,EAAE,CAAC,kBAAG,EAAE;QAChB,WAAW,EAAE,6CAA6C;KAC3D,CAAC;;0DACgB;kCAbP,uBAAuB;IADnC,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;GAC5B,uBAAuB,CAcnC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,4BAA4B,CAChD,EAAE,KAAK,GAAG,EAAE,EAAsB,EAClC,OAAwB;IAExB,6CAA6C;IAC7C,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IACpC,MAAM,UAAU,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,uBAAU,CAAC,CAAC,CAAC,CAAC,IAAA,qBAAa,EAAC,uBAAU,CAAC,CAAA;IAEhF,+BAA+B;IAC/B,MAAM,EAAE,GAAG,UAAU,CAAC,kBAAkB,CAAC,KAAK,CAAC;SAC5C,KAAK,CAAC,2BAA2B,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC;SAC3D,QAAQ,CAAC,uBAAuB,CAAC;SACjC,QAAQ,CAAC,qEAAqE,EAAE;QAC/E,WAAW,EAAE,SAAS;QACtB,SAAS,EAAE,CAAC,mBAAmB,EAAE,iBAAiB,CAAC;KACpD,CAAC,CAAA;IAEJ,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,EAAE,CAAA;IAEjD,IAAI,SAAS,GAAG,CAAC,CAAA;IACjB,IAAI,MAAM,GAAG,CAAC,CAAA;IAEd,KAAK,MAAM,GAAG,IAAI,UAAU,EAAE,CAAC;QAC7B,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,GAAG,CAAC,CAAA;YAC3C,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,MAAM,EAAE,CAAA;gBACR,SAAQ;YACV,CAAC;YAED,MAAM,SAAS,GAAG,MAAM,IAAA,6BAAiB,EAAC,QAAQ,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAA;YACjE,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,MAAM,EAAE,CAAA;gBACR,SAAQ;YACV,CAAC;YAED,MAAM,UAAU,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,CAAA;YACtD,SAAS,EAAE,CAAA;QACb,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,MAAM,EAAE,CAAA;YACR,YAAM,CAAC,IAAI,CAAC,wBAAwB,GAAG,CAAC,EAAE,YAAa,CAAW,CAAC,OAAO,EAAE,CAAC,CAAA;QAC/E,CAAC;IACH,CAAC;IAED,6BAA6B;IAC7B,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAA;IAErC,OAAO;QACL,SAAS,EAAE,UAAU,CAAC,MAAM;QAC5B,SAAS;QACT,MAAM;QACN,SAAS;KACV,CAAA;AACH,CAAC;AAED;;;GAGG;AACH,KAAK,UAAU,eAAe,CAAC,GAAe;IAC5C,+DAA+D;IAC/D,MAAM,MAAM,GAA+B,GAAW,CAAC,QAAQ,CAAA;IAC/D,IAAI,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,MAAM,CAAA;IAE9C,IAAI,CAAC,GAAG,CAAC,IAAI;QAAE,OAAM;IAErB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAI,0BAAe,CAAC,QAErB,CAAA;QACb,IAAI,OAAO,QAAQ,KAAK,UAAU;YAAE,OAAM;QAC1C,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACvC,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,OAAO,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAC1D,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAAE,OAAO,MAAM,CAAA;QAC1C,OAAM;IACR,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,YAAM,CAAC,IAAI,CAAC,4CAA4C,GAAG,CAAC,IAAI,KAAM,CAAW,CAAC,OAAO,EAAE,CAAC,CAAA;QAC5F,OAAM;IACR,CAAC;AACH,CAAC","sourcesContent":["import { Field, Int, ObjectType } from 'type-graphql'\nimport { IsNull } from 'typeorm'\n\nimport { logger } from '@things-factory/env'\nimport { getRepository } from '@things-factory/shell'\n\nimport { STORAGE } from '../../attachment-const'\nimport { Attachment } from './attachment'\nimport { generateThumbnail } from './thumbnail'\n\n@ObjectType({ description: '썸네일 백필 결과' })\nexport class ThumbnailBackfillResult {\n @Field(() => Int, { description: '이번 호출에서 처리 시도한 첨부 개수' })\n attempted!: number\n\n @Field(() => Int, { description: '썸네일 생성·저장 성공 개수' })\n succeeded!: number\n\n @Field(() => Int, { description: '실패(생성 실패/콘텐츠 없음 등) 개수' })\n failed!: number\n\n @Field(() => Int, {\n description: '이번 처리 후에도 남아있는 썸네일 미생성 후보 개수 (대략치). 0 이면 완료'\n })\n remaining!: number\n}\n\n/**\n * 썸네일이 없는 첨부파일을 조회해서 서버에서 썸네일을 생성·저장한다.\n * 하나의 호출당 최대 `limit` 개까지만 처리 — 장시간 점유 방지.\n * 결과의 `remaining > 0` 이면 동일 호출을 반복해 배치 완료시킴.\n *\n * 대상 mimetype 만 시도 (현재 image/*, model/gltf-*).\n * contents 컬럼이 비어 있고 STORAGE.readFile 로도 못 읽으면 skip.\n */\nexport async function backfillAttachmentThumbnails(\n { limit = 20 }: { limit?: number },\n context: ResolverContext\n): Promise<ThumbnailBackfillResult> {\n // ResolverContext 은 things-factory 런타임 전역 타입\n const { domain, tx } = context.state\n const repository = tx ? tx.getRepository(Attachment) : getRepository(Attachment)\n\n // 지원 mimetype 중 썸네일 미생성 대상만 조회\n const qb = repository.createQueryBuilder('att')\n .where('att.domain_id = :domainId', { domainId: domain.id })\n .andWhere('att.thumbnail IS NULL')\n .andWhere('(att.mimetype LIKE :imagePrefix OR att.mimetype IN (:...gltfTypes))', {\n imagePrefix: 'image/%',\n gltfTypes: ['model/gltf-binary', 'model/gltf+json']\n })\n\n const candidates = await qb.take(limit).getMany()\n\n let succeeded = 0\n let failed = 0\n\n for (const att of candidates) {\n try {\n const contents = await resolveContents(att)\n if (!contents) {\n failed++\n continue\n }\n\n const thumbnail = await generateThumbnail(contents, att.mimetype)\n if (!thumbnail) {\n failed++\n continue\n }\n\n await repository.update({ id: att.id }, { thumbnail })\n succeeded++\n } catch (e) {\n failed++\n logger.warn(`[thumbnail-backfill] ${att.id} failed: ${(e as Error).message}`)\n }\n }\n\n // 남은 후보 개수 — 다음 호출 필요 여부 판단용\n const remaining = await qb.getCount()\n\n return {\n attempted: candidates.length,\n succeeded,\n failed,\n remaining\n }\n}\n\n/**\n * contents 획득: DB 저장 모드는 att.contents Buffer 직접, 파일/S3/Azure 모드는\n * STORAGE.readFile 로 경로에서 읽는다. 읽기 실패 시 undefined.\n */\nasync function resolveContents(att: Attachment): Promise<Buffer | undefined> {\n // @ts-ignore — TypeORM 이 contents 를 Buffer 로 반환 (타입 시그니처는 any)\n const fromDb: Buffer | null | undefined = (att as any).contents\n if (fromDb && fromDb.length > 0) return fromDb\n\n if (!att.path) return\n\n try {\n const readFile = (STORAGE as any).readFile as\n | ((path: string, encoding?: string) => Buffer | string)\n | undefined\n if (typeof readFile !== 'function') return\n const result = await readFile(att.path)\n if (typeof result === 'string') return Buffer.from(result)\n if (Buffer.isBuffer(result)) return result\n return\n } catch (e) {\n logger.warn(`[thumbnail-backfill] readFile failed for ${att.path}: ${(e as Error).message}`)\n return\n }\n}\n"]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 바이너리 컨텐츠로부터 썸네일 data URI 생성.
|
|
3
|
+
*
|
|
4
|
+
* mimetype 기반 dispatcher:
|
|
5
|
+
* - image/* → sharp 로 200x200 JPEG 썸네일
|
|
6
|
+
* - model/gltf-binary → headless Three.js 렌더로 적절한 뷰포인트 JPEG
|
|
7
|
+
* - model/gltf+json → 동일
|
|
8
|
+
* - 그 외 → undefined (클라이언트는 generic 아이콘 표시)
|
|
9
|
+
*
|
|
10
|
+
* 반환: `data:image/...;base64,...` 형태 — Attachment.thumbnail 컬럼에 저장 가능.
|
|
11
|
+
* 외부 URL 로 대체 저장하는 것도 스키마상 유효함.
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateThumbnail(contents: Buffer | undefined | null, mimetype: string | undefined): Promise<string | undefined>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateThumbnail = generateThumbnail;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const sharp_1 = tslib_1.__importDefault(require("sharp"));
|
|
6
|
+
const env_1 = require("@things-factory/env");
|
|
7
|
+
const gltf_thumbnail_1 = require("./gltf-thumbnail");
|
|
8
|
+
const IMAGE_THUMB_MAX = 200;
|
|
9
|
+
const IMAGE_THUMB_QUALITY = 70;
|
|
10
|
+
/**
|
|
11
|
+
* 바이너리 컨텐츠로부터 썸네일 data URI 생성.
|
|
12
|
+
*
|
|
13
|
+
* mimetype 기반 dispatcher:
|
|
14
|
+
* - image/* → sharp 로 200x200 JPEG 썸네일
|
|
15
|
+
* - model/gltf-binary → headless Three.js 렌더로 적절한 뷰포인트 JPEG
|
|
16
|
+
* - model/gltf+json → 동일
|
|
17
|
+
* - 그 외 → undefined (클라이언트는 generic 아이콘 표시)
|
|
18
|
+
*
|
|
19
|
+
* 반환: `data:image/...;base64,...` 형태 — Attachment.thumbnail 컬럼에 저장 가능.
|
|
20
|
+
* 외부 URL 로 대체 저장하는 것도 스키마상 유효함.
|
|
21
|
+
*/
|
|
22
|
+
async function generateThumbnail(contents, mimetype) {
|
|
23
|
+
if (!contents || !mimetype)
|
|
24
|
+
return;
|
|
25
|
+
if (mimetype.startsWith('image/')) {
|
|
26
|
+
return generateImageThumbnail(contents);
|
|
27
|
+
}
|
|
28
|
+
if (mimetype === 'model/gltf-binary' || mimetype === 'model/gltf+json') {
|
|
29
|
+
return (0, gltf_thumbnail_1.generateGltfThumbnail)(contents, mimetype);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
async function generateImageThumbnail(contents) {
|
|
34
|
+
try {
|
|
35
|
+
const buffer = await (0, sharp_1.default)(contents)
|
|
36
|
+
.rotate() // EXIF 방향 자동 보정
|
|
37
|
+
.resize(IMAGE_THUMB_MAX, IMAGE_THUMB_MAX, { fit: 'inside', withoutEnlargement: true })
|
|
38
|
+
.jpeg({ quality: IMAGE_THUMB_QUALITY })
|
|
39
|
+
.toBuffer();
|
|
40
|
+
return `data:image/jpeg;base64,${buffer.toString('base64')}`;
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
env_1.logger.warn(`[attachment] image thumbnail generation failed: ${e.message}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=thumbnail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thumbnail.js","sourceRoot":"","sources":["../../../server/service/attachment/thumbnail.ts"],"names":[],"mappings":";;AAqBA,8CAeC;;AApCD,0DAAyB;AAEzB,6CAA4C;AAE5C,qDAAwD;AAExD,MAAM,eAAe,GAAG,GAAG,CAAA;AAC3B,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B;;;;;;;;;;;GAWG;AACI,KAAK,UAAU,iBAAiB,CACrC,QAAmC,EACnC,QAA4B;IAE5B,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ;QAAE,OAAM;IAElC,IAAI,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAClC,OAAO,sBAAsB,CAAC,QAAQ,CAAC,CAAA;IACzC,CAAC;IAED,IAAI,QAAQ,KAAK,mBAAmB,IAAI,QAAQ,KAAK,iBAAiB,EAAE,CAAC;QACvE,OAAO,IAAA,sCAAqB,EAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;IAClD,CAAC;IAED,OAAM;AACR,CAAC;AAED,KAAK,UAAU,sBAAsB,CAAC,QAAgB;IACpD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,IAAA,eAAK,EAAC,QAAQ,CAAC;aACjC,MAAM,EAAE,CAAC,gBAAgB;aACzB,MAAM,CAAC,eAAe,EAAE,eAAe,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,kBAAkB,EAAE,IAAI,EAAE,CAAC;aACrF,IAAI,CAAC,EAAE,OAAO,EAAE,mBAAmB,EAAE,CAAC;aACtC,QAAQ,EAAE,CAAA;QAEb,OAAO,0BAA0B,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAA;IAC9D,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,YAAM,CAAC,IAAI,CAAC,mDAAoD,CAAW,CAAC,OAAO,EAAE,CAAC,CAAA;QACtF,OAAM;IACR,CAAC;AACH,CAAC","sourcesContent":["import sharp from 'sharp'\n\nimport { logger } from '@things-factory/env'\n\nimport { generateGltfThumbnail } from './gltf-thumbnail'\n\nconst IMAGE_THUMB_MAX = 200\nconst IMAGE_THUMB_QUALITY = 70\n\n/**\n * 바이너리 컨텐츠로부터 썸네일 data URI 생성.\n *\n * mimetype 기반 dispatcher:\n * - image/* → sharp 로 200x200 JPEG 썸네일\n * - model/gltf-binary → headless Three.js 렌더로 적절한 뷰포인트 JPEG\n * - model/gltf+json → 동일\n * - 그 외 → undefined (클라이언트는 generic 아이콘 표시)\n *\n * 반환: `data:image/...;base64,...` 형태 — Attachment.thumbnail 컬럼에 저장 가능.\n * 외부 URL 로 대체 저장하는 것도 스키마상 유효함.\n */\nexport async function generateThumbnail(\n contents: Buffer | undefined | null,\n mimetype: string | undefined\n): Promise<string | undefined> {\n if (!contents || !mimetype) return\n\n if (mimetype.startsWith('image/')) {\n return generateImageThumbnail(contents)\n }\n\n if (mimetype === 'model/gltf-binary' || mimetype === 'model/gltf+json') {\n return generateGltfThumbnail(contents, mimetype)\n }\n\n return\n}\n\nasync function generateImageThumbnail(contents: Buffer): Promise<string | undefined> {\n try {\n const buffer = await sharp(contents)\n .rotate() // EXIF 방향 자동 보정\n .resize(IMAGE_THUMB_MAX, IMAGE_THUMB_MAX, { fit: 'inside', withoutEnlargement: true })\n .jpeg({ quality: IMAGE_THUMB_QUALITY })\n .toBuffer()\n\n return `data:image/jpeg;base64,${buffer.toString('base64')}`\n } catch (e) {\n logger.warn(`[attachment] image thumbnail generation failed: ${(e as Error).message}`)\n return\n }\n}\n"]}
|