diff --git a/client/src/components/reels/story-toggle.tsx b/client/src/components/reels/story-toggle.tsx new file mode 100644 index 0000000..7c3c95f --- /dev/null +++ b/client/src/components/reels/story-toggle.tsx @@ -0,0 +1,29 @@ +import { Switch } from "@/components/ui/switch"; + +interface StoryToggleProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +/** + * Double le Reel d'une story sur les pages Facebook sélectionnées. TikTok n'a + * pas de stories : l'option ne le concerne pas. + */ +export function StoryToggle({ checked, onCheckedChange }: StoryToggleProps) { + return ( +
+ + onCheckedChange(value === true)} + data-testid="switch-reel-also-story" + /> +
+ ); +} diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index 47f7cbd..a87972f 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; +import { StoryToggle } from "@/components/reels/story-toggle"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { useToast } from "@/hooks/use-toast"; @@ -83,6 +84,7 @@ export default function MobileNewReel() { const [isPlaying, setIsPlaying] = useState(null); // Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée const [stabilize, setStabilize] = useState(false); + const [alsoStory, setAlsoStory] = useState(false); const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [showLogo, setShowLogo] = useState(true); @@ -272,6 +274,7 @@ export default function MobileNewReel() { description: srtFile ? srtText(srtFile.cues) : overlayText, pageIds: selectedPages, scheduledFor: scheduledDate?.toISOString(), + alsoStory, musicVolume: musicVolume[0] / 100, stabilize: stabilize, drawText: true, @@ -610,6 +613,7 @@ export default function MobileNewReel() { Pages Facebook {facebookPages.map(renderTargetCheckbox)} + )} diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index e1102c5..db1c737 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Switch } from "@/components/ui/switch"; +import { StoryToggle } from "@/components/reels/story-toggle"; import { Slider } from "@/components/ui/slider"; import { Select, @@ -78,6 +79,7 @@ export default function NewReel() { const [drawText, setDrawText] = useState(true); // Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée const [stabilize, setStabilize] = useState(false); + const [alsoStory, setAlsoStory] = useState(false); const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [showLogo, setShowLogo] = useState(true); @@ -415,6 +417,7 @@ export default function NewReel() { description: srtFile ? srtText(srtFile.cues) : overlayText, pageIds: selectedPages, scheduledFor: scheduledDate?.toISOString(), + alsoStory, musicVolume: musicVolume[0] / 100, ttsEnabled, ttsEngine, @@ -938,6 +941,7 @@ export default function NewReel() { Pages Facebook {facebookPages.map(renderTargetCheckbox)} + )} diff --git a/client/src/pages/schedule-reel.tsx b/client/src/pages/schedule-reel.tsx index ad68f64..bb0ae7d 100644 --- a/client/src/pages/schedule-reel.tsx +++ b/client/src/pages/schedule-reel.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; +import { StoryToggle } from "@/components/reels/story-toggle"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; @@ -30,6 +31,7 @@ export default function ScheduleReel() { const [description, setDescription] = useState(""); const [selectedPages, setSelectedPages] = useState([]); const [scheduledDate, setScheduledDate] = useState(undefined); + const [alsoStory, setAlsoStory] = useState(false); const { data: pages = [] } = useQuery({ queryKey: ["/api/pages"] }); const { data: allMedia = [] } = useQuery({ queryKey: ["/api/media"] }); @@ -83,6 +85,7 @@ export default function ScheduleReel() { mediaIds: [selectedVideo!.id], pageIds: selectedPages, scheduledFor: scheduledDate!.toISOString(), + alsoStory, }); return response.json(); }, @@ -250,6 +253,7 @@ export default function ScheduleReel() { Pages Facebook {facebookPages.map(renderTargetCheckbox)} + )} {tiktokAccounts.length > 0 && ( diff --git a/server/routes.ts b/server/routes.ts index 8979701..85aa358 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1268,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise { try { const user = req.user as User; const userId = user.id; - const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body; + const { pageIds, postType, mediaId, mediaIds, alsoStory, ...postFields } = req.body; // Convert mediaIds to standardized format: array of { mediaId, displayOrder } let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = []; @@ -1395,6 +1395,18 @@ export async function registerRoutes(app: Express): Promise { postType: finalPostType, scheduledAt, }); + // Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas + if (finalPostType === 'reel' && alsoStory === true) { + const page = await storage.getSocialPage(pageId); + if (page?.platform === 'facebook') { + await storage.createScheduledPost({ + postId: post.id, + pageId, + postType: 'story', + scheduledAt, + }); + } + } } } } diff --git a/server/services/facebook.ts b/server/services/facebook.ts index ab89979..e9edc4e 100644 --- a/server/services/facebook.ts +++ b/server/services/facebook.ts @@ -514,6 +514,61 @@ export class FacebookService { return finishData.post_id || videoId; } + /** + * Publie une vidéo en story Facebook en envoyant directement les données + * binaires (start → upload → finish sur /video_stories). Sert à doubler un + * Reel d'une story : la vidéo rendue est déjà en mémoire. + */ + async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise { + if (!page.accessToken) throw new Error('No access token found for this page'); + const accessToken = page.accessToken; + const fileSize = videoBuffer.length; + + const startParams = new URLSearchParams({ + access_token: accessToken, + upload_phase: 'start', + }); + const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, { + method: 'POST', + }); + if (!startRes.ok) { + const error = await startRes.json() as FacebookError; + throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`); + } + const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string }; + + const uploadRes = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'Authorization': `OAuth ${accessToken}`, + 'Content-Type': 'application/octet-stream', + 'offset': '0', + 'file_size': fileSize.toString(), + }, + body: new Blob([videoBuffer], { type: 'video/mp4' }), + }); + if (!uploadRes.ok) { + throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`); + } + + const finishParams = new URLSearchParams({ + access_token: accessToken, + upload_phase: 'finish', + video_id: videoId, + }); + const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, { + method: 'POST', + }); + if (!finishRes.ok) { + const error = await finishRes.json() as FacebookError; + throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); + } + + const finishData = await finishRes.json() as { success: boolean; post_id?: string }; + console.log(`✅ Story vidéo publiée sur ${page.pageName}`); + return finishData.post_id || videoId; + } + /** * Publie une vidéo comme Reel Facebook * diff --git a/server/services/reels/publish.ts b/server/services/reels/publish.ts index 0a7a084..bc88dbe 100644 --- a/server/services/reels/publish.ts +++ b/server/services/reels/publish.ts @@ -7,7 +7,7 @@ * cas de l'ancien `publishReel(originalUrl)`). */ -import type { Media } from "@shared/schema"; +import type { Media, SocialPage } from "@shared/schema"; import { storage } from "../../storage"; import { minioService } from "../minio"; import { createVideoThumbnail } from "../thumbnail"; @@ -19,6 +19,8 @@ export interface PagePublishResult { success: boolean; reelId?: string; error?: string; + /** Échec de la story associée : n'empêche pas le Reel d'être publié. */ + storyError?: string; } /** Range la vidéo rendue dans la médiathèque, avec sa vignette. */ @@ -49,6 +51,9 @@ export async function storeRenderedVideo( /** * Publie (ou planifie) la vidéo sur chaque page, crée les entrées * `scheduled_posts` et met à jour le statut du post. + * + * Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok + * n'a pas de stories). */ export async function publishReelToPages(options: { postId: string; @@ -56,8 +61,9 @@ export async function publishReelToPages(options: { videoBuffer: Buffer; description: string; scheduledFor?: string; + alsoStory?: boolean; }): Promise { - const { postId, pageIds, videoBuffer, description, scheduledFor } = options; + const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options; const results: PagePublishResult[] = []; for (const pageId of pageIds) { @@ -82,9 +88,18 @@ export async function publishReelToPages(options: { postType: "reel", scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(), }); + const withStory = alsoStory && page.platform === "facebook"; if (scheduledFor) { // Le planificateur publiera à l'heure dite + if (withStory) { + await storage.createScheduledPost({ + postId, + pageId: page.id, + postType: "story", + scheduledAt: new Date(scheduledFor), + }); + } results.push({ pageId, success: true, reelId: "scheduled" }); continue; } @@ -96,7 +111,8 @@ export async function publishReelToPages(options: { publishedAt: new Date(), externalPostId: reelId, }); - results.push({ pageId, success: true, reelId }); + const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined; + results.push({ pageId, success: true, reelId, storyError }); } else { // TikTok finalise la publication de son côté : l'envoi est marqué comme // effectué pour ne pas republier, et le poller de statut renseignera @@ -126,6 +142,46 @@ export async function publishReelToPages(options: { return results; } +/** + * Publie la story d'une page juste après son Reel : un échec est consigné sur + * son entrée planifiée (visible dans l'historique) et renvoyé, sans faire + * échouer le Reel. + */ +async function publishStory( + postId: string, + page: SocialPage, + videoBuffer: Buffer, +): Promise { + // Créée seulement maintenant : en attente pendant l'envoi du Reel, le + // planificateur aurait pu la publier une seconde fois. + let scheduledStoryId: string | undefined; + try { + ({ id: scheduledStoryId } = await storage.createScheduledPost({ + postId, + pageId: page.id, + postType: "story", + scheduledAt: new Date(), + })); + const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer); + await storage.updateScheduledPost(scheduledStoryId, { + publishedAt: new Date(), + externalPostId: storyId, + }); + return undefined; + } catch (error) { + const message = error instanceof Error ? error.message : "Erreur inconnue"; + console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error); + if (scheduledStoryId) { + // Marquée comme traitée pour que le planificateur ne la republie pas en boucle + await storage.updateScheduledPost(scheduledStoryId, { + publishedAt: new Date(), + error: message, + }).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e)); + } + return message; + } +} + /** Message d'erreur lisible lorsque toutes les publications ont échoué. */ export function describePublishFailure(results: PagePublishResult[]): string { const details = results.map((r) => r.error).filter(Boolean).join(" ; "); diff --git a/server/services/reels/videoPipeline.ts b/server/services/reels/videoPipeline.ts index 44f5a77..7bce929 100644 --- a/server/services/reels/videoPipeline.ts +++ b/server/services/reels/videoPipeline.ts @@ -84,6 +84,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) { videoBuffer, description: params.description || params.overlayText || "", scheduledFor: params.scheduledFor, + alsoStory: params.alsoStory, }); if (!results.some((r) => r.success)) { diff --git a/shared/reel.test.ts b/shared/reel.test.ts index 93a5bea..ec7a826 100644 --- a/shared/reel.test.ts +++ b/shared/reel.test.ts @@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => { it("applique les valeurs par défaut", () => { const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] }); - expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true }); + expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true, alsoStory: false }); }); it("accepte les anciens paramètres encore stockés dans des jobs", () => { diff --git a/shared/reel.ts b/shared/reel.ts index 1946bc6..d2166d1 100644 --- a/shared/reel.ts +++ b/shared/reel.ts @@ -32,6 +32,8 @@ export const videoReelParamsSchema = z.object({ ttsEngine: z.enum(TTS_ENGINES).optional(), ttsStyle: z.enum(TTS_STYLES).optional(), scheduledFor: optionalText, + // Publie aussi la vidéo en story sur les pages Facebook (sans effet sur TikTok) + alsoStory: z.boolean().default(false), // Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même wordDuration: z.number().optional(), fontSize: z.number().int().min(16).max(200).default(64),