Have you ever tried to generate thumbnail for image & video ?
Introduction
日前開發一個 web component:<yahoo-pixelframe-uploader />,它是一個 none UI 的 image / video uploader,Developers 可以透過它來上傳檔案至 一個 yahoo storage service。
這也是筆者第一次開發這種 none UI component,畢竟在不同的服務上總是有自己的設計元素,雖然說透過 CSS custom properties 可以有效地開放樣式給 developers 進行調試,但是如果樣子變異太多的話依然無法符合需求,與其提供一個不合需求的 UI,倒不如不提供 UI,讓用的人自己決定「進」與「出」的長相。
如同上面的 tutorial 演示,Developers 可以自行定義選取檔案的按鈕樣式以及選取完檔案後各元素的長相以及互動行為。
既然是 none UI component,也就是說我們必須要提供足夠多的資訊給用的人,如此一來他們才有辦法在正確的時間點做出最正確的互動與呈現。Image 以及 video thumbnail 便是裡頭基本中的基本,這也是本篇文章想跟大家分享的重點所在 — 如何針對使用者選取的檔案進行縮圖的繪製。
Issue
Form elemet — input[type=file] 這是最基本的選取檔案途徑之一(使用者也可以透過 drag / drop or paste 進行輸入),當使用者完成選取檔案的動作後,Developers 可以透過事件監聽取得方才使用者所選取的檔案,這些檔案的格式為 blob,Developers 了不起僅能取得 type 以及 size 資訊,這些資訊對我們產生 thumbnail 來說遠遠不足。也因此我們需要透過一些方式來進行轉換以及資訊取得,如此才能滿足我們的基本訴求。
Solution
釐清需求後,接下來就需要擬定開發思路,不管是 image 或是 video 通通都可以透過 canvas 來進行繪製。Developers 可以透過 canvas 提供的 method — drawImage() 來將之繪出。要使用這個 method 的先決條件便是需要透過 HTML <img />、<video /> 來 render 檔案,在檔案完成 loading 之後才能取得對應的寬高資訊來製作縮圖。
以下為相關思路
- 由於 <img />、<video /> 無法讀取 blob 格式,所以先透過 FileReader 或是 URL.createObjectURL() 來將 blob 轉換成 dataURL,如此一來 <img />、<video /> 便可以透過 property src 的設置來 render 該檔案。
- 針對上述的 <img /> 、<video /> 監聽 load event,當 event hander 被觸發時便可以取得對應的寬高以及播放秒數。
- 取得步驟二的資訊之後,透過 canvas drawImage() 來讀取且繪製我們預期的 thumbnail 的寬高以及比例。
- 最後再使用 canvas toDataURL() 這個 method 把它轉換成 dataURL 回拋給 client,便完成 thumbnail 的製作。
接下來將針對上述步驟進行對應的代碼導讀:
Step 1:將 blob 轉換成 dataURL 格式並監聽對應的 event 取出所需要的寬高以及播放時間資訊
<input
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.avif,.mov,.mp4,.ogg,.webm"
/>
<script type="module">
const input = document.querySelector('input[type="file"]');
const fetchFileInfo = async ({ type, file }) => {
return new Promise(
(resolve, reject) => {
let host;
if (type === 'image') {
host = new Image();
host.onload = async () => {
const { naturalWidth:width, naturalHeight:height } = host;
window.URL.revokeObjectURL(host.src);
resolve({
type,
width,
height
});
};
} else {
host = document.createElement('video');
host.preload = 'metadata';
host.onloadedmetadata = async () => {
const { videoWidth:width, videoHeight:height, duration } = host;
window.URL.revokeObjectURL(host.src);
resolve({
type,
width,
height,
duration
});
};
}
host.onerror = () => {
window.URL.revokeObjectURL(host.src);
reject(new Error('fetch file info error.'));
};
host.src = window.URL.createObjectURL(file);
}
);
}
input.addEventListener('change'
(event) => {
const {
target: { files }
} = event;
const fileInformations = await Promise.all(
Array.from(files).map(
(file) => {
const type = file.type
.replace(/(.*)\/.*/, '$1')
.toLowerCase();
return fetchFileInfo({ type, file });
}
)
);
// here comes file informarmations
console.log(fileInformations);
}
);
</script>筆者透過 URL.createObjectURL() 來將 blob 轉換成 dataURL,為了避免資源損耗,所以在取得資訊後便會透過 URL.revokeObjectURL() 將之毀滅。
由於 image 以及 video 對於原始寬度以及高度的屬性以及取得不盡相同,所以透過 type 來進行不同的差異化處理與資訊取得。
- image:監聽「load」event,當 event 觸發後, 透過 properties「naturalWidth」以及「naturalHeight」取得寬高資訊。
- video:監聽「loadedmetadata」event,因為我們僅需要取得 metadata 就可以了,不需要將整個 video 都 load 下來。也因為監聽了這個事件,所以必須將 preload 設置為 metadata。當 event 觸發後, 透過 properties「videoWidth」以及「videoHeight」取得寬高資訊。此外,由於 client 可能會對影片總播放時間進行檢核,所以也可以透過 property — duration 將該資訊取出。
Step 2:沿用 Step 1 製作的 dataURL 再進行額外處理,並透過 canvas 進行繪製
<script type="module">
const getThumbnail = async ({ type, width, height, dataURL }) => {
return new Promise(
(resolve, reject) => {
const negative = type === 'image'
? new Image()
: document.createElement('video');
const size = 160;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let sX, sY, sSize;
canvas.width = size;
canvas.height = size;
if (width >= height) {
sX = Math.floor((width - height) / 2);
sY = 0;
sSize = height;
} else {
sX = 0;
sY = Math.floor((height - width) / 2);
sSize = width;
}
negative.onerror = () => {
reject(new Error('fetch file thumbnail error.'));
};
if (type === 'image') {
negative.onload = () => {
ctx.drawImage(negative, sX, sY, sSize, sSize, 0, 0, size, size);
resolve(canvas.toDataURL('image/jpeg', 0.75));
};
negative.src = dataURL;
} else {
negative.autoplay = true;
negative.muted = true;
negative.preload = 'auto';
negative.playsInline = true;
negative.onloadeddata = () => {
// need to set buffer time
const timer = /firefox/i.test(navigator.userAgent) ? 250 : 100;
setTimeout(
() => {
ctx.drawImage(negative, sX, sY, sSize, sSize, 0, 0, size, size);
resolve(canvas.toDataURL('image/jpeg', 0.75));
}
, timer);
};
negative.style.visibility = 'hidden';
negative.style.pointerEvents = 'none';
negative.src = dataURL;
negative.currentTime = 1;
// force loading
negative?.load?.();
}
}
);
}
</script>筆者先獨立一個 getThumbnail() 出來,它吃 type、width、height 以及 dataURL 參數,方便我們針對格式做不同客製化處理。核心思想一樣,要確定完整讀取完之後才能透過 canvas drawImage() 進行繪製。
- 由於筆者要製作的是 160 X 160 的 CROP thumbnail,所以要先處理原始檔案的起始繪製座標以及大小,所以可以從 code 中看到 sX、sY 以及 sSize 的對應處理。
- image 和 video 監聽的事件亦有些許差異。image 比較單純一樣使用「load」即可。video 則可以監聽「loadeddata」事件來取得可繪製的資訊,不過猶豫不同 platform 可能無法正常觸發該事件,所以一樣有些眉角需要處理。有興趣的朋友可以參考筆者另一篇文章 — 「Do you know how to force video loading in iOS Safari ?」
- video 觸發 loadeddata 事件後,最好再設置一個 buffer time 後再進行繪製,避免繪出有問題的 thumbnail 出來。筆者自己體感 firefox 的處理上比較兩光,所以就直接二分設置兩個不同的 buffer time 出來。
Step 3:結合 Step 1 以及 Step 2 的 code,便能有效的取得使用者選取檔案的資本資訊以及 thumbnail 了
<input
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.avif,.mov,.mp4,.ogg,.webm"
/>
<script type="module">
const input = document.querySelector('input[type="file"]');
const getThumbnail = async ({ type, width, height, dataURL }) => {
return new Promise(
(resolve, reject) => {
const negative = type === 'image'
? new Image()
: document.createElement('video');
const size = 160;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let sX, sY, sSize;
canvas.width = size;
canvas.height = size;
if (width >= height) {
sX = Math.floor((width - height) / 2);
sY = 0;
sSize = height;
} else {
sX = 0;
sY = Math.floor((height - width) / 2);
sSize = width;
}
negative.onerror = () => {
reject(new Error('fetch file thumbnail error.'));
};
if (type === 'image') {
negative.onload = () => {
ctx.drawImage(negative, sX, sY, sSize, sSize, 0, 0, size, size);
resolve(canvas.toDataURL('image/jpeg', 0.75));
};
negative.src = dataURL;
} else {
negative.autoplay = true;
negative.muted = true;
negative.preload = 'auto';
negative.playsInline = true;
negative.onloadeddata = () => {
// need to set buffer time
const timer = /firefox/i.test(navigator.userAgent) ? 250 : 100;
setTimeout(
() => {
ctx.drawImage(negative, sX, sY, sSize, sSize, 0, 0, size, size);
resolve(canvas.toDataURL('image/jpeg', 0.75));
}
, timer);
};
negative.style.visibility = 'hidden';
negative.style.pointerEvents = 'none';
negative.src = dataURL;
negative.currentTime = 1;
// force loading
negative?.load?.();
}
}
);
}
const fetchFileInfo = async ({ type, file }) => {
return new Promise(
(resolve, reject) => {
let host;
if (type === 'image') {
host = new Image();
host.onload = async () => {
const { naturalWidth:width, naturalHeight:height } = host;
const thumbnail = await getThumbnail({ type, width, height, dataURL:host.src });
window.URL.revokeObjectURL(host.src);
resolve({
type,
thumbnail,
width,
height
});
};
} else {
host = document.createElement('video');
host.preload = 'metadata';
host.onloadedmetadata = async () => {
const { videoWidth:width, videoHeight:height, duration } = host;
const thumbnail = await getThumbnail({ type, width, height, dataURL:host.src });
window.URL.revokeObjectURL(host.src);
resolve({
type,
thumbnail,
width,
height,
duration
});
};
}
host.onerror = () => {
window.URL.revokeObjectURL(host.src);
reject(new Error('fetch file info error.'));
};
host.src = window.URL.createObjectURL(file);
}
);
}
input.addEventListener('change'
(event) => {
const {
target: { files }
} = event;
const fileInformations = await Promise.all(
Array.from(files).map(
(file) => {
const type = file.type
.replace(/(.*)\/.*/, '$1')
.toLowerCase();
return fetchFileInfo({ type, file });
}
)
);
// here comes file informarmations
console.log(fileInformations);
}
);
</script>Conclusion
透過 <img /> 以及 <video /> 的設置並搭配 Canvas API 的加持便可以輕輕鬆鬆在 client 完成 thumbnail 的繪製。看似簡單其實裡頭還是隱含著一些眉角,尤其是 video 類型的檔案,雖是折騰了筆者許久,但其過程也為筆者帶來了喜悅與視野開拓。
最後,感謝你的閱讀,也希望內容均能對你、我有所助益~
Reference
- Web component:<yahoo-pixelframe-uploader />
- <yahoo-pixelframe-uploader /> tutorial
- Using CSS custom properties
- <input type=”file”>
- Canvas API
- CanvasRenderingContext2D: drawImage() method
- FileReader
- URL: createObjectURL() static method
- HTMLCanvasElement: toDataURL() method
- Do you know how to force video loading in iOS Safari ?