Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/scripts/.seo-corpus-classification-cache.json
Original file line number Diff line number Diff line change
Expand Up @@ -3054,5 +3054,25 @@
"blog:blog-title-checker@8c72649017e09216@20aaebd1": {
"keep": false,
"reason": "Compares Blog2Video against multiple competitors for title checking."
},
"blog:headline-score@2ffa5694cda27a72@20aaebd1": {
"keep": false,
"reason": "Compares Blog2Video against multiple headline tool competitors."
},
"blog:ai-video-generators-that-are-not-slop@717167ff5878ce7f@20aaebd1": {
"keep": false,
"reason": "Discusses AI video generators generally without focusing on Blog2Video"
},
"blog:youtube-title-score-checker@7131da36b2445d56@20aaebd1": {
"keep": false,
"reason": "Compares Blog2Video against multiple competitors."
},
"blog:blog-title-checker@8c72649017e09216@20aaebd1": {
"keep": false,
"reason": "Compares Blog2Video against multiple SEO competitors."
},
"blog:substack-video-70-percent-more-subscribers@bb2f7d7b527f3e49@20aaebd1": {
"keep": false,
"reason": "Primarily describes Substack growth, not Blog2Video features."
}
}
10 changes: 5 additions & 5 deletions frontend/src/components/BlogUrlForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@ export default function BlogUrlForm({ onSubmit, onSubmitBulk, onExtraOptionsChan
const bulkLogoInputRef = useRef<HTMLInputElement>(null);
const [bulkApplyLengthAll, setBulkApplyLengthAll] = useState(true);
const [bulkLengthMasterIndex, setBulkLengthMasterIndex] = useState(0);
const [bulkStockFootage, setBulkStockFootage] = useState<boolean[]>([true]);
const [bulkStockFootage, setBulkStockFootage] = useState<boolean[]>([false]);
const [bulkApplyStockAll, setBulkApplyStockAll] = useState(true);
const [bulkStockMasterIndex, setBulkStockMasterIndex] = useState(0);
const [bulkApplyTemplateAll, setBulkApplyTemplateAll] = useState(true);
Expand Down Expand Up @@ -921,7 +921,7 @@ export default function BlogUrlForm({ onSubmit, onSubmitBulk, onExtraOptionsChan
// Stock footage at generation time: available on every plan and every
// template (builtin, custom, crafted). Free users get a clip on a single
// scene (the backend caps it), paid users on all image-capable scenes.
const [stockFootageEnabled, setStockFootageEnabled] = useState(true);
const [stockFootageEnabled, setStockFootageEnabled] = useState(false);
const stockFootageAvailable = true;
useEffect(() => {
onExtraOptionsChange?.({ stockFootageEnabled: stockFootageAvailable && stockFootageEnabled });
Expand Down Expand Up @@ -1525,7 +1525,7 @@ export default function BlogUrlForm({ onSubmit, onSubmitBulk, onExtraOptionsChan
setBulkCustomVoiceId((prev) => resizeTo(prev, n, ""));
setBulkContentLanguage((prev) => resizeTo(prev, n, "auto"));
setBulkVideoLength((prev) => resizeTo(prev, n, "short"));
setBulkStockFootage((prev) => resizeTo(prev, n, true));
setBulkStockFootage((prev) => resizeTo(prev, n, false));
setBulkAspectRatio((prev) => resizeTo(prev, n, "landscape"));
setBulkVideoStyles((prev) =>
resizeTo(
Expand Down Expand Up @@ -1570,7 +1570,7 @@ export default function BlogUrlForm({ onSubmit, onSubmitBulk, onExtraOptionsChan
setBulkContentLanguage((prev) => [...prev, "auto"]);
setBulkVideoLength((prev) => [...prev, "short"]);
setBulkStockFootage((prev) => {
const next = [...prev, bulkApplyStockAll ? (prev[bulkStockMasterIndex] ?? true) : true];
const next = [...prev, bulkApplyStockAll ? (prev[bulkStockMasterIndex] ?? false) : false];
return next;
});
setBulkAspectRatio((prev) => [...prev, "landscape"]);
Expand Down Expand Up @@ -1748,7 +1748,7 @@ export default function BlogUrlForm({ onSubmit, onSubmitBulk, onExtraOptionsChan
setBulkCustomVoiceId([]);
setBulkContentLanguage(["auto"]);
setBulkVideoLength(["short"]);
setBulkStockFootage([true]);
setBulkStockFootage([false]);
setBulkAspectRatio(["landscape"]);
setBulkVideoStyles([DEFAULT_VIDEO_STYLE]);
setBulkAccentColors([""]);
Expand Down
34 changes: 14 additions & 20 deletions frontend/src/components/remotion/default/layouts/BulletList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,48 +44,42 @@ export const BulletList: React.FC<SceneLayoutProps & { imageUrl?: string }> = (p
Title and bullet copy are unbounded user input; the content column is
vertically centred with no height limit of its own, so a long title or
many/long bullets just grow past the frame and get clipped by the
AbsoluteFill's overflow:hidden. Fit the title to its own budget first,
then the whole bullet list (all points share one descriptionFontSize,
so they're measured as one block) to what remains; a residual overflow
at the list's floor shrinks the title further (NewsHeadline's cascade).
A size the user explicitly picked is honored exactly (minPx === targetPx
makes the hook a no-op). */
AbsoluteFill's overflow:hidden. Title and list each fit against their
own fixed, independent budget. A size the user explicitly picked is
honored exactly (minPx === targetPx makes the hook a no-op).

No give-back cross-talk between the two: a useLayoutEffect+setState
chain reacting to another useFitText's overflow output creates a
multi-render convergence that Remotion's per-frame headless capture can
settle at different points on different frames (confirmed via a real
render — frame-to-frame scene-change score hit 1.0, i.e. maximum, twice
in the first ten frames, in the equivalent newscast/newspaper opening
scenes). */
const titleRef = React.useRef<HTMLHeadingElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);

const actualTitleFontSize = titleFontSize ?? (p ? 60 : 52);
const actualDescriptionFontSize = descriptionFontSize ?? (p ? 37 : 29);

const titleBudgetPx = Math.round(height * (p ? 0.24 : 0.28));
const [titleGiveBackPx, setTitleGiveBackPx] = React.useState(0);
React.useLayoutEffect(() => {
setTitleGiveBackPx(0);
}, [title, points, actualTitleFontSize, actualDescriptionFontSize, titleBudgetPx, p]);

const { px: titlePx } = useFitText(
titleRef,
actualTitleFontSize,
titleFontSizeIsUserSet ? actualTitleFontSize : p ? 28 : 24,
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, titleGiveBackPx, p],
Math.max(1, titleBudgetPx - titleGiveBackPx),
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, p],
titleBudgetPx,
);

const listBudgetPx = Math.round(height * (p ? 0.62 : 0.6));
const { px: descPx, overflowPx: listOverflowPx } = useFitText(
const { px: descPx } = useFitText(
listRef,
actualDescriptionFontSize,
descriptionFontSizeIsUserSet ? actualDescriptionFontSize : p ? 16 : 14,
[points, actualDescriptionFontSize, descriptionFontSizeIsUserSet, titlePx, listBudgetPx, p],
listBudgetPx,
);

React.useLayoutEffect(() => {
if (titleFontSizeIsUserSet) return;
if (listOverflowPx > 0) {
setTitleGiveBackPx((prev) => prev + listOverflowPx);
}
}, [listOverflowPx, titleFontSizeIsUserSet]);

// Scale the bullet-number badge and the key-line proportionally to the
// fitted body size so they don't dwarf shrunken copy.
const keyFontSize = Math.round(descPx * ((actualDescriptionFontSize * 1.1) / actualDescriptionFontSize));
Expand Down
32 changes: 14 additions & 18 deletions frontend/src/components/remotion/default/layouts/EndingSocials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,46 +41,42 @@ export const EndingSocials: React.FC<SceneLayoutProps> = ({
wrapper fontSize. Measure a hidden plain-text mirror instead (JSX below).
The column holding them has no height limit of its own, so long copy
would grow past the frame and get clipped by the AbsoluteFill's
overflow:hidden. Fit the title to its own budget first, then narration to
what's left; a residual overflow at narration's floor shrinks the title
further (NewsHeadline's cascade). A size the user explicitly picked is
honored exactly (minPx === targetPx no-ops). */
overflow:hidden. Title and narration each fit against their own fixed,
independent budget. A size the user explicitly picked is honored exactly
(minPx === targetPx no-ops).

No give-back cross-talk between the two: a useLayoutEffect+setState
chain reacting to another useFitText's overflow output creates a
multi-render convergence that Remotion's per-frame headless capture can
settle at different points on different frames (confirmed via a real
render — frame-to-frame scene-change score hit 1.0, i.e. maximum, twice
in the first ten frames, in the equivalent newscast/newspaper opening
scenes). */
const titleRef = React.useRef<HTMLDivElement>(null);
const narrationRef = React.useRef<HTMLDivElement>(null);

const actualTitleFontSize = titleFontSize ?? (p ? 88 : 79);
const actualDescriptionFontSize = descriptionFontSize ?? (p ? 44 : 36);

const titleBudgetPx = Math.round(height * (p ? 0.16 : 0.18));
const [titleGiveBackPx, setTitleGiveBackPx] = React.useState(0);
React.useLayoutEffect(() => {
setTitleGiveBackPx(0);
}, [title, narration, actualTitleFontSize, actualDescriptionFontSize, titleBudgetPx, p]);

const { px: titlePx } = useFitText(
titleRef,
actualTitleFontSize,
titleFontSizeIsUserSet ? actualTitleFontSize : p ? 38 : 32,
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, titleGiveBackPx, p],
Math.max(1, titleBudgetPx - titleGiveBackPx),
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, p],
titleBudgetPx,
);

const narrationBudgetPx = Math.round(height * (p ? 0.16 : 0.18));
const { px: narrationPx, overflowPx: narrationOverflowPx } = useFitText(
const { px: narrationPx } = useFitText(
narrationRef,
actualDescriptionFontSize,
descriptionFontSizeIsUserSet ? actualDescriptionFontSize : p ? 20 : 16,
[narration, actualDescriptionFontSize, descriptionFontSizeIsUserSet, titlePx, narrationBudgetPx, p],
narrationBudgetPx,
);

React.useLayoutEffect(() => {
if (titleFontSizeIsUserSet) return;
if (narrationOverflowPx > 0) {
setTitleGiveBackPx((prev) => prev + narrationOverflowPx);
}
}, [narrationOverflowPx, titleFontSizeIsUserSet]);

// --- CONFIGURATION ---
const vanishDurationFrames = 30;
const vanishStartFrame = durationInFrames - vanishDurationFrames;
Expand Down
32 changes: 14 additions & 18 deletions frontend/src/components/remotion/default/layouts/HeroImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,46 +46,42 @@ export const HeroImage: React.FC<SceneLayoutProps> = (props) => {
Title and narration are unbounded user input. The content column is
centred with no height limit of its own (padding: 80px on all sides), so
long copy just grows past the frame and is clipped by overflow:hidden.
Fit the title to its own budget first, then narration to what's left; a
residual overflow at the narration's floor shrinks the title further
(same cascade as NewsHeadline in the newspaper template). A size the user
explicitly picked is honored exactly (minPx === targetPx no-ops). */
Title and narration each fit against their own fixed, independent
budget. A size the user explicitly picked is honored exactly (minPx ===
targetPx no-ops).

No give-back cross-talk between the two: a useLayoutEffect+setState
chain reacting to another useFitText's overflow output creates a
multi-render convergence that Remotion's per-frame headless capture can
settle at different points on different frames (confirmed via a real
render — frame-to-frame scene-change score hit 1.0, i.e. maximum, twice
in the first ten frames, in the equivalent newscast/newspaper opening
scenes). */
const titleRef = React.useRef<HTMLHeadingElement>(null);
const narrationRef = React.useRef<HTMLParagraphElement>(null);

const actualTitleFontSize = titleFontSize ?? 76;
const actualDescriptionFontSize = descriptionFontSize ?? 40;

const titleBudgetPx = Math.round(height * (hasImage ? 0.34 : 0.3));
const [titleGiveBackPx, setTitleGiveBackPx] = React.useState(0);
React.useLayoutEffect(() => {
setTitleGiveBackPx(0);
}, [title, narration, actualTitleFontSize, actualDescriptionFontSize, titleBudgetPx, hasImage]);

const { px: titlePx } = useFitText(
titleRef,
actualTitleFontSize,
titleFontSizeIsUserSet ? actualTitleFontSize : 34,
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, titleGiveBackPx, hasImage],
Math.max(1, titleBudgetPx - titleGiveBackPx),
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, hasImage],
titleBudgetPx,
);

const narrationBudgetPx = Math.round(height * (hasImage ? 0.28 : 0.4));
const { px: narrationPx, overflowPx: narrationOverflowPx } = useFitText(
const { px: narrationPx } = useFitText(
narrationRef,
actualDescriptionFontSize,
descriptionFontSizeIsUserSet ? actualDescriptionFontSize : 18,
[narration, actualDescriptionFontSize, descriptionFontSizeIsUserSet, titlePx, narrationBudgetPx, hasImage],
narrationBudgetPx,
);

React.useLayoutEffect(() => {
if (titleFontSizeIsUserSet) return;
if (narrationOverflowPx > 0) {
setTitleGiveBackPx((prev) => prev + narrationOverflowPx);
}
}, [narrationOverflowPx, titleFontSizeIsUserSet]);

// --- ENTRANCE ANIMATIONS ---
const contentEntranceDelay = 10;
const contentSpringVal = spring({
Expand Down
34 changes: 15 additions & 19 deletions frontend/src/components/remotion/default/layouts/ImageCaption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,47 +37,43 @@ export const ImageCaption: React.FC<SceneLayoutProps> = ({
Title and narration are unbounded user input. The text column is a plain
centred flex block with no height limit of its own (it shares the frame
with the image, when present), so long copy just grows past the frame
and is clipped by the AbsoluteFill's overflow:hidden. Fit the title to
its own budget first, then narration to what's left; a residual overflow
at narration's floor shrinks the title further (NewsHeadline's cascade).
A size the user explicitly picked is honored exactly (minPx === targetPx
makes the hook a no-op). */
and is clipped by the AbsoluteFill's overflow:hidden. Title and
narration each fit against their own fixed, independent budget. A size
the user explicitly picked is honored exactly (minPx === targetPx makes
the hook a no-op).

No give-back cross-talk between the two: a useLayoutEffect+setState
chain reacting to another useFitText's overflow output creates a
multi-render convergence that Remotion's per-frame headless capture can
settle at different points on different frames (confirmed via a real
render — frame-to-frame scene-change score hit 1.0, i.e. maximum, twice
in the first ten frames, in the equivalent newscast/newspaper opening
scenes). */
const titleRef = React.useRef<HTMLHeadingElement>(null);
const narrationRef = React.useRef<HTMLParagraphElement>(null);

const actualTitleFontSize = titleFontSize ?? (p ? 57 : 56);
const actualDescriptionFontSize = descriptionFontSize ?? (p ? 37 : 32);

const titleBudgetPx = Math.round(videoHeight * (hasImage ? 0.2 : 0.24));
const [titleGiveBackPx, setTitleGiveBackPx] = React.useState(0);
React.useLayoutEffect(() => {
setTitleGiveBackPx(0);
}, [title, narration, actualTitleFontSize, actualDescriptionFontSize, titleBudgetPx, hasImage]);

const { px: titlePx } = useFitText(
titleRef,
actualTitleFontSize,
titleFontSizeIsUserSet ? actualTitleFontSize : p ? 26 : 24,
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, titleGiveBackPx, hasImage],
Math.max(1, titleBudgetPx - titleGiveBackPx),
[title, actualTitleFontSize, titleFontSizeIsUserSet, titleBudgetPx, hasImage],
titleBudgetPx,
);

const narrationBudgetPx = Math.round(videoHeight * (hasImage ? 0.28 : 0.4));
const { px: narrationPx, overflowPx: narrationOverflowPx } = useFitText(
const { px: narrationPx } = useFitText(
narrationRef,
actualDescriptionFontSize,
descriptionFontSizeIsUserSet ? actualDescriptionFontSize : p ? 16 : 14,
[narration, actualDescriptionFontSize, descriptionFontSizeIsUserSet, titlePx, narrationBudgetPx, hasImage],
narrationBudgetPx,
);

React.useLayoutEffect(() => {
if (titleFontSizeIsUserSet) return;
if (narrationOverflowPx > 0) {
setTitleGiveBackPx((prev) => prev + narrationOverflowPx);
}
}, [narrationOverflowPx, titleFontSizeIsUserSet]);

// --- Initial animations (spring in) ---

// Image springs in with zoom
Expand Down
32 changes: 14 additions & 18 deletions frontend/src/components/remotion/default/layouts/QuoteCallout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,46 +33,42 @@ export const QuoteCallout: React.FC<SceneLayoutProps> = ({
Quote and author are unbounded user input; the text column has no height
limit of its own (just `alignItems:stretch` next to the accent bar), so a
long quote grows past the frame and gets clipped by the AbsoluteFill's
overflow:hidden. Fit the quote to its own budget first, then the author
line to what's left; a residual overflow at the author's floor shrinks
the quote further (NewsHeadline's cascade). A size the user explicitly
picked is honored exactly (minPx === targetPx makes the hook a no-op). */
overflow:hidden. Quote and author each fit against their own fixed,
independent budget. A size the user explicitly picked is honored exactly
(minPx === targetPx makes the hook a no-op).

No give-back cross-talk between the two: a useLayoutEffect+setState
chain reacting to another useFitText's overflow output creates a
multi-render convergence that Remotion's per-frame headless capture can
settle at different points on different frames (confirmed via a real
render — frame-to-frame scene-change score hit 1.0, i.e. maximum, twice
in the first ten frames, in the equivalent newscast/newspaper opening
scenes). */
const quoteRef = React.useRef<HTMLParagraphElement>(null);
const authorRef = React.useRef<HTMLParagraphElement>(null);

const actualQuoteFontSize = titleFontSize ?? (p ? 54 : 49);
const actualAuthorFontSize = descriptionFontSize ?? (p ? 30 : 26);

const quoteBudgetPx = Math.round(height * (p ? 0.5 : 0.46));
const [quoteGiveBackPx, setQuoteGiveBackPx] = React.useState(0);
React.useLayoutEffect(() => {
setQuoteGiveBackPx(0);
}, [displayQuote, displayAuthor, actualQuoteFontSize, actualAuthorFontSize, quoteBudgetPx, p]);

const { px: quotePx } = useFitText(
quoteRef,
actualQuoteFontSize,
titleFontSizeIsUserSet ? actualQuoteFontSize : p ? 24 : 20,
[displayQuote, actualQuoteFontSize, titleFontSizeIsUserSet, quoteBudgetPx, quoteGiveBackPx, p],
Math.max(1, quoteBudgetPx - quoteGiveBackPx),
[displayQuote, actualQuoteFontSize, titleFontSizeIsUserSet, quoteBudgetPx, p],
quoteBudgetPx,
);

const authorBudgetPx = Math.round(height * 0.14);
const { px: authorPx, overflowPx: authorOverflowPx } = useFitText(
const { px: authorPx } = useFitText(
authorRef,
actualAuthorFontSize,
descriptionFontSizeIsUserSet ? actualAuthorFontSize : p ? 16 : 14,
[displayAuthor, actualAuthorFontSize, descriptionFontSizeIsUserSet, quotePx, authorBudgetPx, p],
authorBudgetPx,
);

React.useLayoutEffect(() => {
if (titleFontSizeIsUserSet) return;
if (authorOverflowPx > 0) {
setQuoteGiveBackPx((prev) => prev + authorOverflowPx);
}
}, [authorOverflowPx, titleFontSizeIsUserSet]);

const barH = interpolate(frame, [0, 25], [0, 100], {
extrapolateRight: "clamp",
});
Expand Down
Loading
Loading