Remotion LabRemotion Lab
建構應用處理使用者影片上傳

處理使用者影片上傳

學習如何在使用者上傳影片前即時預覽,並實作樂觀更新以提升使用體驗

在一個讓使用者上傳和編輯影片的應用程式中,我們可以透過在上傳完成之前就將影片載入播放器來創造更好的使用者體驗。好消息是:這可以相當容易地實現!

允許使用者上傳

我們有一個組件,它返回一個包含 URL 作為來源的 <OffthreadVideo> 標籤。

MyComposition.tsx
import { AbsoluteFill, OffthreadVideo } from 'remotion';
 
type VideoProps = {
  videoURL: string;
};
 
export const MyComponent: React.FC<VideoProps> = ({ videoURL }) => {
  return (
    <AbsoluteFill>
      <OffthreadVideo src={videoURL} />
    </AbsoluteFill>
  );
};

影片 URL 將從 Remotion Player 傳遞給我們的組件。使用 <input type="file"> 元素,我們允許使用者上傳。一旦檔案完全上傳到雲端,URL 就會被設定,組件就可以使用它來顯示影片。

App.tsx
import { Player } from '@remotion/player';
import { useCallback, useState } from 'react';
import { MyComposition } from './MyComposition';
 
// upload 是一個範例函數,當檔案上傳到雲端時返回 URL
const upload = async (file: File): Promise<string> => {
  // 實作你的上傳邏輯(例如:上傳到 AWS S3、GCS 等)
  return 'https://exampleBucketName.s3.ExampleAwsRegion.amazonaws.com/file.mp4';
};
 
export const RemotionPlayer: React.FC = () => {
  const [videoUrl, setVideoUrl] = useState<string | null>(null);
 
  const handleChange = useCallback(
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      if (event.target.files === null) {
        return;
      }
 
      const file = event.target.files[0];
      // upload 是一個範例函數,當檔案上傳到雲端時返回 URL
      // 例如:cloudURL = https://exampleBucketName.s3.ExampleAwsRegion.amazonaws.com
      const cloudURL = await upload(file);
      setVideoUrl(cloudURL);
    },
    []
  );
 
  return (
    <div>
      {videoUrl === null ? null : (
        <Player
          component={MyComposition}
          inputProps={{ videoURL: videoUrl }}
          durationInFrames={150}
          fps={30}
          compositionWidth={1920}
          compositionHeight={1080}
          style={{ width: '100%' }}
          controls
        />
      )}
 
      <input type="file" accept="video/*" onChange={handleChange} />
    </div>
  );
};

upload() 函數的實作是依賴於具體的雲端供應商的,本文不展示具體實作。我們假設它是一個接受檔案、上傳並返回 URL 的函數。

樂觀更新

當我們開始上傳檔案時,可以使用 URL.createObjectURL() 建立一個 blob URL,用來在 <Video> 標籤中顯示本地檔案。當檔案上傳完成並獲得遠端 URL 時,組件應改為使用遠端 URL 作為來源。

App.tsx(樂觀更新)
import { Player } from '@remotion/player';
import { useCallback, useState } from 'react';
import { MyComposition } from './MyComposition';
 
const upload = async (file: File): Promise<string> => {
  return 'https://example.com/video.mp4';
};
 
// 定義影片狀態的類型
type VideoState =
  | {
      type: 'empty';
    }
  | {
      type: 'blob' | 'cloud';
      url: string;
    };
 
export const RemotionPlayer: React.FC = () => {
  const [videoState, setVideoState] = useState<VideoState>({
    type: 'empty',
  });
 
  const handleChange = useCallback(
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      if (event.target.files === null) {
        return;
      }
 
      const file = event.target.files[0];
 
      // 立即建立 blob URL 以顯示本地預覽
      const blobUrl = URL.createObjectURL(file);
      setVideoState({ type: 'blob', url: blobUrl });
 
      // 在背景上傳檔案
      const cloudUrl = await upload(file);
 
      // 上傳完成後切換到雲端 URL
      setVideoState({ type: 'cloud', url: cloudUrl });
 
      // 釋放 blob URL 以節省記憶體
      URL.revokeObjectURL(blobUrl);
    },
    []
  );
 
  return (
    <div>
      {videoState.type !== 'empty' ? (
        <Player
          component={MyComposition}
          inputProps={{ videoURL: videoState.url }}
          durationInFrames={150}
          fps={30}
          compositionWidth={1920}
          compositionHeight={1080}
          style={{ width: '100%' }}
          controls
        />
      ) : null}
 
      <input type="file" accept="video/*" onChange={handleChange} />
    </div>
  );
};

這將使使用者在將影片拖入輸入欄位時立即看到影片預覽。在本地影片不再使用後呼叫 URL.revokeObjectURL() 是一個良好做法,可以釋放使用的記憶體。

注意:請盡快用真實 URL 替換 blob: URL。由於 blob: URL 不支援 HTTP Range 標頭,使用 blob: URL 時影片跳轉效能會下降。

顯示上傳進度

為了提升使用體驗,你可以顯示上傳進度:

App.tsx(帶上傳進度)
import { Player } from '@remotion/player';
import { useCallback, useState } from 'react';
import { MyComposition } from './MyComposition';
 
const uploadWithProgress = async (
  file: File,
  onProgress: (percent: number) => void
): Promise<string> => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
 
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const percent = Math.round((e.loaded / e.total) * 100);
        onProgress(percent);
      }
    });
 
    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        const response = JSON.parse(xhr.responseText);
        resolve(response.url);
      } else {
        reject(new Error('上傳失敗'));
      }
    });
 
    xhr.open('POST', '/api/upload');
    const formData = new FormData();
    formData.append('file', file);
    xhr.send(formData);
  });
};
 
type UploadState =
  | { type: 'idle' }
  | { type: 'uploading'; progress: number; previewUrl: string }
  | { type: 'done'; url: string };
 
export const RemotionPlayerWithProgress: React.FC = () => {
  const [uploadState, setUploadState] = useState<UploadState>({ type: 'idle' });
 
  const handleChange = useCallback(
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      if (!event.target.files?.length) return;
 
      const file = event.target.files[0];
      const previewUrl = URL.createObjectURL(file);
 
      setUploadState({ type: 'uploading', progress: 0, previewUrl });
 
      try {
        const cloudUrl = await uploadWithProgress(file, (progress) => {
          setUploadState((prev) =>
            prev.type === 'uploading' ? { ...prev, progress } : prev
          );
        });
 
        setUploadState({ type: 'done', url: cloudUrl });
        URL.revokeObjectURL(previewUrl);
      } catch (error) {
        console.error('上傳失敗:', error);
        setUploadState({ type: 'idle' });
        URL.revokeObjectURL(previewUrl);
      }
    },
    []
  );
 
  const videoUrl =
    uploadState.type === 'uploading'
      ? uploadState.previewUrl
      : uploadState.type === 'done'
        ? uploadState.url
        : null;
 
  return (
    <div>
      {videoUrl && (
        <Player
          component={MyComposition}
          inputProps={{ videoURL: videoUrl }}
          durationInFrames={150}
          fps={30}
          compositionWidth={1920}
          compositionHeight={1080}
          style={{ width: '100%' }}
          controls
        />
      )}
 
      {uploadState.type === 'uploading' && (
        <div>
          <progress value={uploadState.progress} max={100} />
          <span>上傳中... {uploadState.progress}%</span>
        </div>
      )}
 
      <input
        type="file"
        accept="video/*"
        onChange={handleChange}
        disabled={uploadState.type === 'uploading'}
      />
    </div>
  );
};

驗證影片相容性

在上傳影片之前,你應該驗證瀏覽器是否可以解碼該影片。這可以節省頻寬,並為使用者提供即時的回饋。關於驗證使用者影片的完整指南,包括處理不相容格式,請參閱驗證使用者影片

另請參閱