Cours Vibe coding avec l'IA · Module 10: Clone de TikTok
Duo, Collage et montage vidéo
Dans cette leçon6
Les fonctionnalités collaboratives de TikTok - le duo (écran partagé) et le collage (réagir à un extrait) - sont essentielles pour le contenu viral !
Fonctionnalité Duo
1interface DuetVideo {
2 id: string;
3 originalVideoId: string;
4 originalVideo: Video;
5 duetVideoUrl: string; // vidéo du côté droit
6
7 layout: 'side-by-side' | 'top-bottom' | 'green-screen';
8 audioMix: {
9 originalVolume: number; // 0-1
10 duetVolume: number; // 0-1
11 };
12
13 createdAt: Date;
14}
15
16// Créer un duo
17function createDuet(original: Video, duetRecording: Blob): DuetVideo {
18 return {
19 id: generateId(),
20 originalVideoId: original.id,
21 originalVideo: original,
22 duetVideoUrl: URL.createObjectURL(duetRecording),
23 layout: 'side-by-side',
24 audioMix: {
25 originalVolume: 0.7,
26 duetVolume: 1.0
27 },
28 createdAt: new Date()
29 };
30}Enregistreur de duo
1'use client';
2
3import { useState, useRef } from 'react';
4
5interface DuetRecorderProps {
6 originalVideo: Video;
7 onComplete: (duetVideo: DuetVideo) => void;
8}
9
10export default function DuetRecorder({
11 originalVideo,
12 onComplete
13}: DuetRecorderProps) {
14 const [isRecording, setIsRecording] = useState(false);
15 const [layout, setLayout] = useState<'side-by-side' | 'top-bottom'>('side-by-side');
16
17 const originalVideoRef = useRef<HTMLVideoElement>(null);
18 const userVideoRef = useRef<HTMLVideoElement>(null);
19
20 const startRecording = () => {
21 // Démarrer les deux vidéos simultanément
22 originalVideoRef.current?.play();
23 setIsRecording(true);
24 // Enregistrer la vidéo de l'utilisateur...
25 };
26
27 return (
28 <div className="h-screen bg-black flex flex-col">
29 {/* Sélecteur de disposition */}
30 <div className="bg-gray-900 p-3 flex gap-2">
31 <button
32 onClick={() => setLayout('side-by-side')}
33 className={`px-4 py-2 rounded ${
34 layout === 'side-by-side'
35 ? 'bg-white text-black'
36 : 'bg-gray-700 text-white'
37 }`}
38 >
39 Côte à côte
40 </button>
41 <button
42 onClick={() => setLayout('top-bottom')}
43 className={`px-4 py-2 rounded ${
44 layout === 'top-bottom'
45 ? 'bg-white text-black'
46 : 'bg-gray-700 text-white'
47 }`}
48 >
49 Haut et bas
50 </button>
51 </div>
52
53 {/* Aperçu de la vidéo */}
54 <div className={`flex-1 flex ${
55 layout === 'side-by-side' ? 'flex-row' : 'flex-col'
56 }`}>
57 {/* Vidéo originale */}
58 <div className="flex-1 relative">
59 <video
60 ref={originalVideoRef}
61 src={originalVideo.videoUrl}
62 className="w-full h-full object-cover"
63 />
64 <div className="absolute top-4 left-4 bg-black/50 px-3 py-2 rounded-full text-white text-sm">
65 Originale
66 </div>
67 </div>
68
69 {/* Vidéo de l'utilisateur */}
70 <div className="flex-1 relative border-l-2 border-white">
71 <video
72 ref={userVideoRef}
73 autoPlay
74 playsInline
75 muted
76 className="w-full h-full object-cover"
77 />
78 <div className="absolute top-4 right-4 bg-black/50 px-3 py-2 rounded-full text-white text-sm">
79 Toi
80 </div>
81
82 {isRecording && (
83 <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
84 <div className="w-16 h-16 border-4 border-red-500 rounded-full animate-pulse"></div>
85 </div>
86 )}
87 </div>
88 </div>
89
90 {/* Commandes */}
91 <div className="bg-black p-6">
92 <div className="flex justify-center">
93 <button
94 onClick={startRecording}
95 className="w-20 h-20 bg-red-500 rounded-full border-4 border-white flex items-center justify-center"
96 >
97 {isRecording ? (
98 <div className="w-8 h-8 bg-white rounded"></div>
99 ) : (
100 <span className="text-white text-3xl">⏺</span>
101 )}
102 </button>
103 </div>
104 </div>
105 </div>
106 );
107}Fonctionnalité Collage
1interface StitchVideo {
2 id: string;
3 originalVideoId: string;
4 originalClip: {
5 videoUrl: string;
6 startTime: number; // à quelle seconde démarrer
7 duration: number; // 1-5 secondes
8 };
9 reactionVideoUrl: string; // ta réaction
10
11 createdAt: Date;
12}
13
14// Le collage permet d'utiliser un extrait de 1 à 5 secondes de la vidéo originale,
15// puis d'ajouter ta réactionCréateur de collage
1'use client';
2
3import { useState } from 'react';
4
5interface StitchCreatorProps {
6 originalVideo: Video;
7 onComplete: (stitch: StitchVideo) => void;
8}
9
10export default function StitchCreator({
11 originalVideo,
12 onComplete
13}: StitchCreatorProps) {
14 const [clipStart, setClipStart] = useState(0);
15 const [clipDuration, setClipDuration] = useState(5);
16 const [phase, setPhase] = useState<'select-clip' | 'record-reaction'>('select-clip');
17
18 return (
19 <div className="min-h-screen bg-black text-white p-4">
20 {phase === 'select-clip' && (
21 <>
22 <h2 className="text-2xl font-bold mb-4">Sélectionne un extrait (1-5 secondes)</h2>
23
24 {/* Aperçu de la vidéo */}
25 <video
26 src={originalVideo.videoUrl}
27 className="w-full rounded-lg mb-4"
28 controls
29 />
30
31 {/* Sélecteur de timeline */}
32 <div className="mb-6">
33 <div className="flex items-center justify-between mb-2 text-sm">
34 <span>Début : {clipStart.toFixed(1)}s</span>
35 <span>Durée : {clipDuration}s</span>
36 </div>
37
38 <input
39 type="range"
40 min={0}
41 max={originalVideo.duration - clipDuration}
42 step={0.1}
43 value={clipStart}
44 onChange={(e) => setClipStart(parseFloat(e.target.value))}
45 className="w-full"
46 />
47
48 <input
49 type="range"
50 min={1}
51 max={5}
52 value={clipDuration}
53 onChange={(e) => setClipDuration(parseInt(e.target.value))}
54 className="w-full mt-2"
55 />
56 </div>
57
58 <button
59 onClick={() => setPhase('record-reaction')}
60 className="w-full py-4 bg-red-500 rounded-full font-bold"
61 >
62 Suivant : enregistrer la réaction
63 </button>
64 </>
65 )}
66
67 {phase === 'record-reaction' && (
68 <>
69 <h2 className="text-2xl font-bold mb-4">Enregistre ta réaction</h2>
70
71 <div className="space-y-4 mb-6">
72 {/* Aperçu de l'extrait sélectionné */}
73 <div className="bg-gray-900 rounded-lg p-4">
74 <p className="text-sm text-gray-400 mb-2">Ton extrait sélectionné :</p>
75 <p className="font-semibold">
76 {clipStart.toFixed(1)}s - {(clipStart + clipDuration).toFixed(1)}s
77 </p>
78 </div>
79
80 {/* Instructions */}
81 <div className="bg-blue-900/30 rounded-lg p-4 border border-blue-500">
82 <p className="text-sm">
83 L'extrait sera d'abord lu, puis tu pourras enregistrer ta réaction
84 </p>
85 </div>
86 </div>
87
88 <button className="w-full py-4 bg-red-500 rounded-full font-bold">
89 Démarrer l'enregistrement
90 </button>
91 </>
92 )}
93 </div>
94 );
95}Rogneur et éditeur de vidéo
1'use client';
2
3interface VideoEditorProps {
4 videoBlob: Blob;
5 onSave: (editedVideo: Blob, metadata: any) => void;
6}
7
8export default function VideoEditor({ videoBlob, onSave }: VideoEditorProps) {
9 const [trimStart, setTrimStart] = useState(0);
10 const [trimEnd, setTrimEnd] = useState(60);
11 const [filters, setFilters] = useState<Filter[]>([]);
12 const [caption, setCaption] = useState('');
13
14 const videoUrl = URL.createObjectURL(videoBlob);
15
16 return (
17 <div className="min-h-screen bg-black text-white p-4">
18 <h2 className="text-2xl font-bold mb-4">Monter la vidéo</h2>
19
20 {/* Aperçu de la vidéo */}
21 <video
22 src={videoUrl}
23 className="w-full rounded-lg mb-6"
24 controls
25 />
26
27 {/* Timeline de rognage */}
28 <div className="mb-6">
29 <h3 className="font-bold mb-2">Rogner</h3>
30 <div className="flex items-center gap-4">
31 <input
32 type="range"
33 min={0}
34 max={trimEnd}
35 value={trimStart}
36 onChange={(e) => setTrimStart(parseFloat(e.target.value))}
37 className="flex-1"
38 />
39 <span className="text-sm">{trimStart.toFixed(1)}s</span>
40 </div>
41 <div className="flex items-center gap-4 mt-2">
42 <input
43 type="range"
44 min={trimStart}
45 max={60}
46 value={trimEnd}
47 onChange={(e) => setTrimEnd(parseFloat(e.target.value))}
48 className="flex-1"
49 />
50 <span className="text-sm">{trimEnd.toFixed(1)}s</span>
51 </div>
52 </div>
53
54 {/* Filtres */}
55 <div className="mb-6">
56 <h3 className="font-bold mb-2">Filtres</h3>
57 <div className="flex gap-2 overflow-x-auto">
58 {['None', 'Vintage', 'B&W', 'Warm', 'Cool', 'Vibrant'].map(filter => (
59 <button
60 key={filter}
61 className="px-4 py-2 bg-gray-800 rounded-full whitespace-nowrap"
62 >
63 {filter}
64 </button>
65 ))}
66 </div>
67 </div>
68
69 {/* Légende */}
70 <div className="mb-6">
71 <h3 className="font-bold mb-2">Légende</h3>
72 <textarea
73 value={caption}
74 onChange={(e) => setCaption(e.target.value)}
75 placeholder="Écris une légende..."
76 className="w-full bg-gray-900 text-white p-3 rounded-lg resize-none"
77 rows={3}
78 />
79 <p className="text-xs text-gray-500 mt-1">
80 {caption.length}/150 caractères
81 </p>
82 </div>
83
84 {/* Bouton Publier */}
85 <button
86 onClick={() => onSave(videoBlob, { trimStart, trimEnd, filters, caption })}
87 className="w-full py-4 bg-red-500 rounded-full font-bold"
88 >
89 Publier
90 </button>
91 </div>
92 );
93}Résumé
Duo - écran partagé avec la vidéo originale Dispositions du duo - côte à côte, haut-bas Mixage audio - équilibre entre l'originale et le duo Collage - utiliser un extrait de 1 à 5 s + ta réaction Sélecteur d'extrait - choisir un moment de la vidéo originale Rogneur de vidéo - couper le début/la fin Filtres - effets vintage, N&B, chaud, froid Éditeur de légende - texte + hashtags Lecture simultanée - l'originale se lit pendant l'enregistrement du duo
Tu as maintenant un clone complet de TikTok avec un fil vertical, des sons, le duo/collage et le montage !
À bientôt !
Code de cette leçon : App.jsx
1// Page de découverte des sons
2import { useState } from 'react';
3
4const sounds = [
5 { id: 1, name: 'Pirate\'s Anthem', artist: 'Redbeard', useCount: 1250000, isTrending: true, icon: '' },
6 { id: 2, name: 'Sea Shanty', artist: 'Morgan Crew', useCount: 890000, isTrending: true, icon: '' },
7 { id: 3, name: 'Treasure Found', artist: 'Jack Sparrow', useCount: 2340000, isTrending: true, icon: '' },
8 { id: 4, name: 'Battle Theme', artist: 'Blackbeard', useCount: 450000, isTrending: false, icon: '' }
9];
10
11export default function App() {
12 const [activeTab, setActiveTab] = useState('trending');
13 const [searchQuery, setSearchQuery] = useState('');
14
15 const filteredSounds = sounds.filter(sound => {
16 if (searchQuery) {
17 return sound.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
18 sound.artist.toLowerCase().includes(searchQuery.toLowerCase());
19 }
20 if (activeTab === 'trending') return sound.isTrending;
21 return true;
22 });
23
24 const formatCount = (count) => {
25 if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
26 if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
27 return count.toString();
28 };
29
30 return (
31 <div className="min-h-screen bg-black text-white">
32 {/* En-tête */}
33 <div className="sticky top-0 bg-black z-10 border-b border-gray-800">
34 <div className="p-4">
35 <h1 className="text-2xl font-bold mb-4">Sons</h1>
36
37 <input
38 type="text"
39 value={searchQuery}
40 onChange={(e) => setSearchQuery(e.target.value)}
41 placeholder="Rechercher des sons..."
42 className="w-full bg-gray-900 text-white px-4 py-3 rounded-lg outline-none"
43 />
44 </div>
45
46 {/* Onglets */}
47 <div className="flex border-b border-gray-800">
48 {['trending', 'favorites', 'discover'].map(tab => (
49 <button
50 key={tab}
51 onClick={() => setActiveTab(tab)}
52 className={`flex-1 py-3 text-sm font-semibold capitalize ${
53 activeTab === tab ? 'border-b-2 border-white' : 'text-gray-500'
54 }`}
55 >
56 {tab}
57 </button>
58 ))}
59 </div>
60 </div>
61
62 {/* Liste des sons */}
63 <div className="p-4 space-y-3">
64 {filteredSounds.map(sound => (
65 <div key={sound.id} className="bg-gray-900 rounded-lg p-4 flex items-center gap-4">
66 {/* Icône */}
67 <div className="w-16 h-16 rounded-lg bg-gradient-to-r from-pink-500 to-purple-500 flex items-center justify-center text-4xl">
68 {sound.icon}
69 </div>
70
71 {/* Infos */}
72 <div className="flex-1">
73 <h3 className="font-bold text-white">{sound.name}</h3>
74 <p className="text-sm text-gray-400">{sound.artist}</p>
75 <div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
76 <span>{formatCount(sound.useCount)} vidéos</span>
77 {sound.isTrending && (
78 <span className="flex items-center gap-1 text-pink-500">
79 Tendance
80 </span>
81 )}
82 </div>
83 </div>
84
85 {/* Actions */}
86 <div className="flex flex-col gap-2">
87 <button className="px-4 py-2 bg-white text-black rounded-full text-sm font-semibold hover:bg-gray-200">
88 Utiliser le son
89 </button>
90 <button className="p-2 hover:bg-gray-800 rounded-full">Favori</button>
91 </div>
92 </div>
93 ))}
94 </div>
95 </div>
96 );
97}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Qu'est-ce qu'un Duo sur TikTok ?
2. Que décrit l'objet audioMix dans un Duo ?
Voici 2 des 9 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri vertical
Ordonne les valeurs du type de mise en page du Duo telles qu'elles apparaissent dans la définition (de haut en bas) :
- Tri vertical
Ordonne les champs renvoyés par createDuet (tels qu'ils apparaissent dans le code) :
- Éditeur de code
Interface d'enregistrement de Duo
- Tri horizontal
Ordonne les étapes de création d'un Stitch :
- Tri vertical
Ordonne les phases de création d'un Stitch dans StitchCreator (de haut en bas) :
- Éditeur de code
Interface de montage vidéo
- Tri horizontal
Ordonne la ligne qui crée l'URL d'aperçu à partir de videoBlob :
- Clique dans l'ordre
Construis la lecture du nombre de caractères de la légende saisie (clique dans l'ordre) :
- Tri vertical
Ordonne les sections de l'éditeur vidéo telles qu'elles apparaissent dans l'interface (de haut en bas) :
- Clique dans l'ordre
Construis le réglage de la mise en page du Duo sur side-by-side (clique dans l'ordre) :
- Tri vertical
Ordonne le parcours utilisateur complet dans Bobines du Trésor (de haut en bas) :
- Tri horizontal
Ordonne l'expression qui calcule la fin de l'extrait de Stitch sélectionné :
- Clique dans l'ordre
Construis le titre de la page Sons en JSX (clique dans l'ordre) :