Reels : option « Publier aussi en story » sur Facebook

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPMYHUDaZyrG3xRCM5iGVr
This commit is contained in:
Claude committed 2026-10-05 06:50:33 +00:00
1 parent 7806f8bdc8
commit 086ef956e2
10 files changed
+172 -5

No files matched your search

@@ -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 (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 p-3">
<label htmlFor="reel-also-story" className="space-y-0.5 cursor-pointer">
<div className="text-sm font-medium">Publier aussi en story</div>
<div className="text-xs text-muted-foreground">
La vidéo est aussi publiée en story sur les pages Facebook sélectionnées (visible 24 h).
</div>
</label>
<Switch
id="reel-also-story"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
data-testid="switch-reel-also-story"
/>
</div>
);
}
+4
View File
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -83,6 +84,7 @@ export default function MobileNewReel() {
const [isPlaying, setIsPlaying] = useState<string | null>(null); const [isPlaying, setIsPlaying] = useState<string | null>(null);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée // 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 [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true); const [showLogo, setShowLogo] = useState(true);
@@ -272,6 +274,7 @@ export default function MobileNewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText, description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(), scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100, musicVolume: musicVolume[0] / 100,
stabilize: stabilize, stabilize: stabilize,
drawText: true, drawText: true,
@@ -610,6 +613,7 @@ export default function MobileNewReel() {
Pages Facebook Pages Facebook
</div> </div>
{facebookPages.map(renderTargetCheckbox)} {facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div> </div>
)} )}
+4
View File
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { import {
Select, Select,
@@ -78,6 +79,7 @@ export default function NewReel() {
const [drawText, setDrawText] = useState(true); const [drawText, setDrawText] = useState(true);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée // 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 [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true); const [showLogo, setShowLogo] = useState(true);
@@ -415,6 +417,7 @@ export default function NewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText, description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(), scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100, musicVolume: musicVolume[0] / 100,
ttsEnabled, ttsEnabled,
ttsEngine, ttsEngine,
@@ -938,6 +941,7 @@ export default function NewReel() {
Pages Facebook Pages Facebook
</div> </div>
{facebookPages.map(renderTargetCheckbox)} {facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div> </div>
)} )}
+4
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { StoryToggle } from "@/components/reels/story-toggle";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
@@ -30,6 +31,7 @@ export default function ScheduleReel() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [selectedPages, setSelectedPages] = useState<string[]>([]); const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined); const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [alsoStory, setAlsoStory] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] }); const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] }); const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] });
@@ -83,6 +85,7 @@ export default function ScheduleReel() {
mediaIds: [selectedVideo!.id], mediaIds: [selectedVideo!.id],
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate!.toISOString(), scheduledFor: scheduledDate!.toISOString(),
alsoStory,
}); });
return response.json(); return response.json();
}, },
@@ -250,6 +253,7 @@ export default function ScheduleReel() {
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook <SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
</Label> </Label>
{facebookPages.map(renderTargetCheckbox)} {facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div> </div>
)} )}
{tiktokAccounts.length > 0 && ( {tiktokAccounts.length > 0 && (
+13 -1
View File
@@ -1268,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; 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 } // Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = []; let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
@@ -1395,6 +1395,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
postType: finalPostType, postType: finalPostType,
scheduledAt, 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,
});
}
}
} }
} }
} }
+55
View File
@@ -514,6 +514,61 @@ export class FacebookService {
return finishData.post_id || videoId; 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<string> {
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 * Publie une vidéo comme Reel Facebook
* *
+59 -3
View File
@@ -7,7 +7,7 @@
* cas de l'ancien `publishReel(originalUrl)`). * cas de l'ancien `publishReel(originalUrl)`).
*/ */
import type { Media } from "@shared/schema"; import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage"; import { storage } from "../../storage";
import { minioService } from "../minio"; import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail"; import { createVideoThumbnail } from "../thumbnail";
@@ -19,6 +19,8 @@ export interface PagePublishResult {
success: boolean; success: boolean;
reelId?: string; reelId?: string;
error?: 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. */ /** 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 * Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post. * `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: { export async function publishReelToPages(options: {
postId: string; postId: string;
@@ -56,8 +61,9 @@ export async function publishReelToPages(options: {
videoBuffer: Buffer; videoBuffer: Buffer;
description: string; description: string;
scheduledFor?: string; scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> { }): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor } = options; const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = []; const results: PagePublishResult[] = [];
for (const pageId of pageIds) { for (const pageId of pageIds) {
@@ -82,9 +88,18 @@ export async function publishReelToPages(options: {
postType: "reel", postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(), scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
}); });
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) { if (scheduledFor) {
// Le planificateur publiera à l'heure dite // 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" }); results.push({ pageId, success: true, reelId: "scheduled" });
continue; continue;
} }
@@ -96,7 +111,8 @@ export async function publishReelToPages(options: {
publishedAt: new Date(), publishedAt: new Date(),
externalPostId: reelId, 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 { } else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme // TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera // effectué pour ne pas republier, et le poller de statut renseignera
@@ -126,6 +142,46 @@ export async function publishReelToPages(options: {
return results; 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<string | undefined> {
// 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é. */ /** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string { export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; "); const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
+1
View File
@@ -84,6 +84,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
videoBuffer, videoBuffer,
description: params.description || params.overlayText || "", description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor, scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
}); });
if (!results.some((r) => r.success)) { if (!results.some((r) => r.success)) {
+1 -1
View File
@@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => {
it("applique les valeurs par défaut", () => { it("applique les valeurs par défaut", () => {
const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] }); 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", () => { it("accepte les anciens paramètres encore stockés dans des jobs", () => {
+2
View File
@@ -32,6 +32,8 @@ export const videoReelParamsSchema = z.object({
ttsEngine: z.enum(TTS_ENGINES).optional(), ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsStyle: z.enum(TTS_STYLES).optional(), ttsStyle: z.enum(TTS_STYLES).optional(),
scheduledFor: optionalText, 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 // Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même
wordDuration: z.number().optional(), wordDuration: z.number().optional(),
fontSize: z.number().int().min(16).max(200).default(64), fontSize: z.number().int().min(16).max(200).default(64),