From 086ef956e21e03ff7c60ee949d8c00c8b58b2c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:50:33 +0000 Subject: [PATCH] =?UTF-8?q?Reels=20:=20option=20=C2=AB=20Publier=20aussi?= =?UTF-8?q?=20en=20story=20=C2=BB=20sur=20Facebook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une case sur les écrans de création (bureau, mobile) et de programmation d'un Reel double la publication d'une story vidéo sur les pages Facebook sélectionnées. TikTok n'ayant pas de stories, l'option ne le concerne pas. - Publication immédiate : la story part juste après le Reel, à partir de la vidéo rendue envoyée en binaire (nouvelle publishVideoStoryFromBuffer). Un échec de la story est noté dans l'historique sans faire échouer le Reel. - Publication programmée : une entrée « story » est planifiée à côté du Reel ; le planificateur la publie comme les autres stories. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPMYHUDaZyrG3xRCM5iGVr --- client/src/components/reels/story-toggle.tsx | 29 +++++++++ client/src/pages/mobile/new-reel.tsx | 4 ++ client/src/pages/new-reel.tsx | 4 ++ client/src/pages/schedule-reel.tsx | 4 ++ server/routes.ts | 14 ++++- server/services/facebook.ts | 55 +++++++++++++++++ server/services/reels/publish.ts | 62 +++++++++++++++++++- server/services/reels/videoPipeline.ts | 1 + shared/reel.test.ts | 2 +- shared/reel.ts | 2 + 10 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 client/src/components/reels/story-toggle.tsx 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),