@adstage/web-sdk 2.5.3 → 2.6.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/README.md +59 -0
- package/dist/index.cjs.js +2073 -1045
- package/dist/index.d.ts +55 -12
- package/dist/index.esm.js +2073 -1045
- package/dist/index.standalone.js +2073 -1045
- package/package.json +1 -1
- package/src/constants/endpoints.ts +0 -1
- package/src/core/{AdStage.ts → adstage.ts} +36 -8
- package/src/index.ts +9 -3
- package/src/managers/ads/advertisement-event-tracker.ts +15 -11
- package/src/managers/ads/carousel-slider-manager.ts +90 -12
- package/src/managers/ads/slider-event-tracker.ts +57 -0
- package/src/managers/ads/text-transition-manager.ts +91 -26
- package/src/modules/ads/ad-renderer.ts +259 -0
- package/src/modules/ads/{AdsModule.ts → ads-module.ts} +202 -21
- package/src/modules/ads/interfaces/i-ad-renderer.ts +77 -0
- package/src/modules/ads/renderers/banner-ad-renderer.ts +414 -0
- package/src/modules/ads/renderers/base-ad-renderer.ts +340 -0
- package/src/modules/ads/renderers/interstitial-ad-renderer.ts +256 -0
- package/src/modules/ads/renderers/native-ad-renderer.ts +154 -0
- package/src/modules/ads/renderers/text-ad-renderer.ts +120 -0
- package/src/modules/ads/renderers/video-ad-renderer.ts +433 -0
- package/src/modules/config/{ConfigModule.ts → config-module.ts} +1 -5
- package/src/react/{AdStageProvider.tsx → ad-stage-provider.tsx} +1 -1
- package/src/react/index.ts +2 -2
- package/src/types/config.ts +2 -184
- package/src/utils/ad-click-handler.ts +155 -0
- package/src/utils/text-ad-utils.ts +37 -0
- package/src/dummy/ads_dummy.json +0 -84
- package/src/modules/ads/AdRenderer.ts +0 -735
- package/src/renderers/banner-renderer.ts +0 -35
- package/src/renderers/base-renderer.ts +0 -209
- package/src/renderers/index.ts +0 -71
- package/src/renderers/interstitial-renderer.ts +0 -70
- package/src/renderers/native-renderer.ts +0 -35
- package/src/renderers/text-renderer.ts +0 -94
- package/src/renderers/video-renderer.ts +0 -63
- /package/src/modules/events/{EventsModule.ts → events-module.ts} +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
import { AdSlot, Advertisement, AdType } from '../../../types/advertisement';
|
|
3
|
+
import { AdvertisementEventTracker } from '../../../managers/ads/advertisement-event-tracker';
|
|
4
|
+
import { CarouselSliderManager } from '../../../managers/ads/carousel-slider-manager';
|
|
5
|
+
import { BaseAdRenderer } from './base-ad-renderer';
|
|
6
|
+
import { AdRenderOptions } from '../interfaces/i-ad-renderer'; 광고 전용 렌더러
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AdSlot, Advertisement, AdType, AdEventType } from '../../../types/advertisement';
|
|
10
|
+
import { AdvertisementEventTracker } from '../../../managers/ads/advertisement-event-tracker';
|
|
11
|
+
import { CarouselSliderManager } from '../../../managers/ads/carousel-slider-manager';
|
|
12
|
+
import { BaseAdRenderer } from './base-ad-renderer';
|
|
13
|
+
import { AdRenderOptions } from '../interfaces/i-ad-renderer';
|
|
14
|
+
import { AdClickHandler } from '../../../utils/ad-click-handler';
|
|
15
|
+
|
|
16
|
+
export class BannerAdRenderer extends BaseAdRenderer {
|
|
17
|
+
constructor(debug: boolean = false, advertisementEventTracker?: AdvertisementEventTracker | null) {
|
|
18
|
+
super(AdType.BANNER, debug, advertisementEventTracker);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 배너 광고 기본 높이
|
|
23
|
+
*/
|
|
24
|
+
getDefaultHeight(): string {
|
|
25
|
+
return '250px';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 단일 배너 광고 렌더링
|
|
30
|
+
*/
|
|
31
|
+
async renderAdElement(slot: AdSlot, advertisement: Advertisement): Promise<void> {
|
|
32
|
+
const container = document.getElementById(slot.containerId);
|
|
33
|
+
if (!container) return;
|
|
34
|
+
|
|
35
|
+
const adElement = document.createElement('div');
|
|
36
|
+
adElement.className = 'adstage-ad adstage-banner-ad';
|
|
37
|
+
|
|
38
|
+
const optimizedHeight = (slot as any).optimizedHeight;
|
|
39
|
+
const containerElement = container.parentElement || container;
|
|
40
|
+
|
|
41
|
+
if (optimizedHeight) {
|
|
42
|
+
adElement.style.width = '100%';
|
|
43
|
+
adElement.style.height = String(optimizedHeight);
|
|
44
|
+
} else {
|
|
45
|
+
const config = slot.config as any;
|
|
46
|
+
const options: AdRenderOptions = {
|
|
47
|
+
width: config?.width,
|
|
48
|
+
height: config?.height
|
|
49
|
+
};
|
|
50
|
+
const { width, height } = this.calculateAdSize(containerElement, options, { debug: this.debug });
|
|
51
|
+
adElement.style.width = width;
|
|
52
|
+
adElement.style.height = height;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (advertisement.imageUrl) {
|
|
56
|
+
await this.renderOptimizedBannerImage(adElement, advertisement, slot);
|
|
57
|
+
} else {
|
|
58
|
+
adElement.innerHTML = `<div>${advertisement.title || 'Banner Ad'}</div>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 클릭 이벤트 추가 (공통 컴포넌트 사용)
|
|
62
|
+
AdClickHandler.addClickEventForRenderer(
|
|
63
|
+
adElement,
|
|
64
|
+
advertisement,
|
|
65
|
+
slot,
|
|
66
|
+
() => this.createEventTrackingCallback(),
|
|
67
|
+
this.debug,
|
|
68
|
+
'Banner'
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
container.innerHTML = '';
|
|
72
|
+
container.appendChild(adElement);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 다중 배너 광고 렌더링 (슬라이더)
|
|
77
|
+
*/
|
|
78
|
+
async renderMultipleAds(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
|
|
79
|
+
const container = document.getElementById(slot.containerId);
|
|
80
|
+
if (!container) {
|
|
81
|
+
throw new Error(`Container not found: ${slot.containerId}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 배너 광고를 위한 컨테이너 최적화
|
|
85
|
+
await this.optimizeContainerForBannerAds(slot, advertisements);
|
|
86
|
+
|
|
87
|
+
const trackEventCallback = this.createEventTrackingCallback();
|
|
88
|
+
|
|
89
|
+
const optimizedSliderOptions = {
|
|
90
|
+
autoSlideInterval: ((slot.config as any)?.slideInterval || 5000) / 1000,
|
|
91
|
+
...slot.config,
|
|
92
|
+
optimizedHeight: (slot as any).optimizedHeight,
|
|
93
|
+
aspectRatio: (slot as any).aspectRatio
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const sliderElement = CarouselSliderManager.createSliderContainer(
|
|
97
|
+
slot,
|
|
98
|
+
advertisements,
|
|
99
|
+
optimizedSliderOptions,
|
|
100
|
+
trackEventCallback,
|
|
101
|
+
this.debug
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (sliderElement) {
|
|
105
|
+
container.innerHTML = '';
|
|
106
|
+
container.appendChild(sliderElement);
|
|
107
|
+
|
|
108
|
+
if (this.debug) {
|
|
109
|
+
console.log(`🎠 Banner carousel created for slot: ${slot.id} with ${advertisements.length} ads (optimized: ${(slot as any).optimizedHeight || 'default'})`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 여러 광고의 최적 컨테이너 크기 계산
|
|
116
|
+
*/
|
|
117
|
+
async calculateOptimalContainerSize(
|
|
118
|
+
advertisements: Advertisement[],
|
|
119
|
+
containerWidth: number
|
|
120
|
+
): Promise<{ width: string; height: string; aspectRatio: number }> {
|
|
121
|
+
if (!advertisements.length) {
|
|
122
|
+
return {
|
|
123
|
+
width: '100%',
|
|
124
|
+
height: this.getDefaultHeight(),
|
|
125
|
+
aspectRatio: 16 / 9
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const imageDimensions = await Promise.allSettled(
|
|
131
|
+
advertisements
|
|
132
|
+
.filter(ad => ad.imageUrl)
|
|
133
|
+
.map(ad => this.loadImageDimensions(ad.imageUrl!))
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const validDimensions = imageDimensions
|
|
137
|
+
.filter((result): result is PromiseFulfilledResult<{ width: number; height: number }> =>
|
|
138
|
+
result.status === 'fulfilled'
|
|
139
|
+
)
|
|
140
|
+
.map(result => result.value);
|
|
141
|
+
|
|
142
|
+
if (validDimensions.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
width: '100%',
|
|
145
|
+
height: this.getDefaultHeight(),
|
|
146
|
+
aspectRatio: 16 / 9
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const strategy = this.selectOptimalSizeStrategy(validDimensions);
|
|
151
|
+
const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
|
|
152
|
+
|
|
153
|
+
if (this.debug) {
|
|
154
|
+
console.log(`📐 Optimal banner container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
width: '100%',
|
|
159
|
+
height: `${optimalHeight}px`,
|
|
160
|
+
aspectRatio: containerWidth / optimalHeight
|
|
161
|
+
};
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.warn('Failed to calculate optimal banner size, using defaults:', error);
|
|
164
|
+
return {
|
|
165
|
+
width: '100%',
|
|
166
|
+
height: this.getDefaultHeight(),
|
|
167
|
+
aspectRatio: 16 / 9
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 배너 광고를 위한 컨테이너 최적화
|
|
174
|
+
*/
|
|
175
|
+
async optimizeContainerForBannerAds(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
|
|
176
|
+
try {
|
|
177
|
+
const container = document.getElementById(slot.containerId);
|
|
178
|
+
const adElement = document.getElementById(slot.id);
|
|
179
|
+
if (!container || !adElement) return;
|
|
180
|
+
|
|
181
|
+
const containerWidth = container.getBoundingClientRect().width || 300;
|
|
182
|
+
const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth);
|
|
183
|
+
|
|
184
|
+
adElement.style.height = optimalSize.height;
|
|
185
|
+
(slot as any).optimizedHeight = optimalSize.height;
|
|
186
|
+
(slot as any).aspectRatio = optimalSize.aspectRatio;
|
|
187
|
+
|
|
188
|
+
if (this.debug) {
|
|
189
|
+
console.log(`🔧 Banner container optimized for ${advertisements.length} ads: ${optimalSize.height}`);
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.warn('Banner container optimization failed, using default size:', error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 최적 크기 조정 전략 선택
|
|
198
|
+
*/
|
|
199
|
+
private selectOptimalSizeStrategy(
|
|
200
|
+
dimensions: { width: number; height: number }[]
|
|
201
|
+
): 'average' | 'common' | 'dominant' {
|
|
202
|
+
const aspectRatios = dimensions.map(d => d.width / d.height);
|
|
203
|
+
|
|
204
|
+
const ratioGroups = new Map<string, number>();
|
|
205
|
+
aspectRatios.forEach(ratio => {
|
|
206
|
+
const roundedRatio = Math.round(ratio * 10) / 10;
|
|
207
|
+
const key = roundedRatio.toString();
|
|
208
|
+
ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const maxGroup = Math.max(...ratioGroups.values());
|
|
212
|
+
const totalImages = dimensions.length;
|
|
213
|
+
|
|
214
|
+
if (maxGroup / totalImages >= 0.7) {
|
|
215
|
+
return 'dominant';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const standardRatios = [16 / 9, 4 / 3, 1 / 1, 3 / 2];
|
|
219
|
+
const standardCount = aspectRatios.filter(ratio =>
|
|
220
|
+
standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)
|
|
221
|
+
).length;
|
|
222
|
+
|
|
223
|
+
if (standardCount / totalImages >= 0.5) {
|
|
224
|
+
return 'common';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return 'average';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 전략에 따른 최적 높이 계산
|
|
232
|
+
*/
|
|
233
|
+
private calculateOptimalHeight(
|
|
234
|
+
dimensions: { width: number; height: number }[],
|
|
235
|
+
containerWidth: number,
|
|
236
|
+
strategy: 'average' | 'common' | 'dominant'
|
|
237
|
+
): number {
|
|
238
|
+
const aspectRatios = dimensions.map(d => d.width / d.height);
|
|
239
|
+
|
|
240
|
+
switch (strategy) {
|
|
241
|
+
case 'dominant': {
|
|
242
|
+
const ratioGroups = new Map<string, { ratio: number; count: number }>();
|
|
243
|
+
aspectRatios.forEach(ratio => {
|
|
244
|
+
const roundedRatio = Math.round(ratio * 10) / 10;
|
|
245
|
+
const key = roundedRatio.toString();
|
|
246
|
+
const existing = ratioGroups.get(key);
|
|
247
|
+
if (existing) {
|
|
248
|
+
existing.count++;
|
|
249
|
+
} else {
|
|
250
|
+
ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const dominantGroup = Array.from(ratioGroups.values()).reduce((max, current) =>
|
|
255
|
+
current.count > max.count ? current : max
|
|
256
|
+
);
|
|
257
|
+
return Math.round(containerWidth / dominantGroup.ratio);
|
|
258
|
+
}
|
|
259
|
+
case 'common': {
|
|
260
|
+
const standardRatios = [
|
|
261
|
+
{ ratio: 16 / 9, name: '16:9' },
|
|
262
|
+
{ ratio: 4 / 3, name: '4:3' },
|
|
263
|
+
{ ratio: 1 / 1, name: '1:1' },
|
|
264
|
+
{ ratio: 3 / 2, name: '3:2' }
|
|
265
|
+
];
|
|
266
|
+
const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
|
|
267
|
+
const bestStandard = standardRatios.reduce((best, current) =>
|
|
268
|
+
Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best
|
|
269
|
+
);
|
|
270
|
+
if (this.debug) {
|
|
271
|
+
console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
|
|
272
|
+
}
|
|
273
|
+
return Math.round(containerWidth / bestStandard.ratio);
|
|
274
|
+
}
|
|
275
|
+
case 'average':
|
|
276
|
+
default: {
|
|
277
|
+
const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
|
|
278
|
+
return Math.round(containerWidth / averageRatio);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 이미지 로드 및 실제 크기 획득
|
|
285
|
+
*/
|
|
286
|
+
private loadImageDimensions(imageUrl: string): Promise<{ width: number; height: number }> {
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const img = new Image();
|
|
289
|
+
img.onload = () => {
|
|
290
|
+
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
291
|
+
};
|
|
292
|
+
img.onerror = () => {
|
|
293
|
+
reject(new Error(`Failed to load image: ${imageUrl}`));
|
|
294
|
+
};
|
|
295
|
+
img.src = imageUrl;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 이미지와 컨테이너 비율을 고려한 최적화 스타일 적용
|
|
301
|
+
*/
|
|
302
|
+
private applyOptimizedImageStyle(
|
|
303
|
+
img: HTMLImageElement,
|
|
304
|
+
imageAspectRatio: number,
|
|
305
|
+
containerAspectRatio: number
|
|
306
|
+
): void {
|
|
307
|
+
const ratio = imageAspectRatio / containerAspectRatio;
|
|
308
|
+
|
|
309
|
+
if (Math.abs(ratio - 1) < 0.1) {
|
|
310
|
+
img.style.objectFit = 'cover';
|
|
311
|
+
img.style.objectPosition = 'center';
|
|
312
|
+
} else if (ratio > 1.3) {
|
|
313
|
+
img.style.objectFit = 'contain';
|
|
314
|
+
img.style.objectPosition = 'center';
|
|
315
|
+
img.style.backgroundColor = '#f0f0f0';
|
|
316
|
+
} else if (ratio < 0.7) {
|
|
317
|
+
img.style.objectFit = 'cover';
|
|
318
|
+
img.style.objectPosition = 'center';
|
|
319
|
+
} else {
|
|
320
|
+
img.style.objectFit = 'cover';
|
|
321
|
+
img.style.objectPosition = 'center';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (this.debug) {
|
|
325
|
+
console.log(`🎨 Banner image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 배너 이미지 최적화 렌더링 - public으로 변경
|
|
331
|
+
*/
|
|
332
|
+
async renderOptimizedBannerImage(
|
|
333
|
+
container: HTMLElement,
|
|
334
|
+
advertisement: Advertisement,
|
|
335
|
+
slot: AdSlot
|
|
336
|
+
): Promise<HTMLImageElement> {
|
|
337
|
+
const img = document.createElement('img');
|
|
338
|
+
|
|
339
|
+
img.style.width = '100%';
|
|
340
|
+
img.style.height = '100%';
|
|
341
|
+
img.style.display = 'block';
|
|
342
|
+
img.style.borderRadius = '8px';
|
|
343
|
+
img.alt = advertisement.title || 'Banner Advertisement';
|
|
344
|
+
|
|
345
|
+
container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #999;">Loading...</div>';
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
if (!advertisement.imageUrl) {
|
|
349
|
+
throw new Error('Image URL is not provided');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const imageDimensions = await this.loadImageDimensions(advertisement.imageUrl);
|
|
353
|
+
|
|
354
|
+
const containerRect = container.getBoundingClientRect();
|
|
355
|
+
const containerWidth = containerRect.width;
|
|
356
|
+
const containerHeight = containerRect.height;
|
|
357
|
+
|
|
358
|
+
if (this.debug) {
|
|
359
|
+
console.log(`📸 Banner image dimensions: ${imageDimensions.width}x${imageDimensions.height}`);
|
|
360
|
+
console.log(`📦 Banner container dimensions: ${containerWidth}x${containerHeight}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const imageAspectRatio = imageDimensions.width / imageDimensions.height;
|
|
364
|
+
const containerAspectRatio = containerWidth / containerHeight;
|
|
365
|
+
|
|
366
|
+
this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
|
|
367
|
+
|
|
368
|
+
img.src = advertisement.imageUrl;
|
|
369
|
+
|
|
370
|
+
container.innerHTML = '';
|
|
371
|
+
container.appendChild(img);
|
|
372
|
+
|
|
373
|
+
// 클릭 이벤트 추가 (공통 컴포넌트 사용)
|
|
374
|
+
AdClickHandler.addClickEventForRenderer(
|
|
375
|
+
img,
|
|
376
|
+
advertisement,
|
|
377
|
+
slot,
|
|
378
|
+
() => this.createEventTrackingCallback(),
|
|
379
|
+
this.debug,
|
|
380
|
+
'Banner'
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (this.debug) {
|
|
384
|
+
console.log(`✅ Optimized banner image rendered for ad: ${advertisement._id}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return img;
|
|
388
|
+
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error('❌ Failed to load optimized banner image:', error);
|
|
391
|
+
|
|
392
|
+
if (advertisement.imageUrl) {
|
|
393
|
+
img.src = advertisement.imageUrl;
|
|
394
|
+
img.style.objectFit = 'cover';
|
|
395
|
+
img.style.objectPosition = 'center';
|
|
396
|
+
|
|
397
|
+
container.innerHTML = '';
|
|
398
|
+
container.appendChild(img);
|
|
399
|
+
|
|
400
|
+
// 클릭 이벤트 추가 (공통 컴포넌트 사용)
|
|
401
|
+
AdClickHandler.addClickEventForRenderer(
|
|
402
|
+
img,
|
|
403
|
+
advertisement,
|
|
404
|
+
slot,
|
|
405
|
+
() => this.createEventTrackingCallback(),
|
|
406
|
+
this.debug,
|
|
407
|
+
'Banner'
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return img;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|