Merge pull request #124 from R0m1k3/claude/practical-newton-aal4xl

Reels : option « Publier aussi en story » sur Facebook
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-05 09:03:19 +02:00
commit 01315bf281
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 { 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<string | null>(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
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
+4
View File
@@ -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
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
+4
View File
@@ -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<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [alsoStory, setAlsoStory] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
const { data: allMedia = [] } = useQuery<Media[]>({ 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() {
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
</Label>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
{tiktokAccounts.length > 0 && (
+13 -1
View File
@@ -1268,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
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<Server> {
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,
});
}
}
}
}
}
+55
View File
@@ -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<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
*
+59 -3
View File
@@ -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<PagePublishResult[]> {
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<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é. */
export function describePublishFailure(results: PagePublishResult[]): string {
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,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
+1 -1
View File
@@ -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", () => {
+2
View File
@@ -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),