mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Merge pull request #124 from R0m1k3/claude/practical-newton-aal4xl
Reels : option « Publier aussi en story » sur Facebook
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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(" ; ");
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user