From 6e6a4834cbab04851034d61f54c1fd4db519d33a Mon Sep 17 00:00:00 2001 From: Jeffrey Date: Mon, 3 Aug 2026 16:53:25 -0700 Subject: [PATCH] Color and align Fuser tutorial chapters --- captutor/bin/captutor-wallpaper.swift | 24 +++++------ captutor/bin/recompose-existing.mjs | 1 + captutor/captutor.mjs | 3 ++ captutor/lib/brand-chrome.mjs | 46 ++++++++++++++++----- captutor/lib/deliver.mjs | 24 ++++++++--- captutor/screenplays/apps-quickstart.mjs | 19 +++++---- captutor/test/brand-chrome.test.mjs | 31 ++++++++++++-- captutor/test/tutorial-screenplays.test.mjs | 13 ++++++ captutor/themes/fuser.mjs | 21 ++++++---- 9 files changed, 135 insertions(+), 47 deletions(-) diff --git a/captutor/bin/captutor-wallpaper.swift b/captutor/bin/captutor-wallpaper.swift index a537c7f9a0..cb410f0cfd 100644 --- a/captutor/bin/captutor-wallpaper.swift +++ b/captutor/bin/captutor-wallpaper.swift @@ -811,21 +811,21 @@ private final class FuserDimensionalView: NSView { CATransaction.begin() CATransaction.setAnimationDuration(0.55) background.colors = (dark ? [ - NSColor(hex: 0x050505).mixed(with:accent, amount:0.10), - NSColor(hex: 0x141414).mixed(with:accent, amount:0.16), - NSColor(hex: 0x080808).mixed(with:accent, amount:0.08), + accent.mixed(with:NSColor(hex: 0x17131C), amount:0.34), + accent.mixed(with:NSColor(hex: 0x2A2430), amount:0.22), + accent.mixed(with:NSColor(hex: 0x0F0D12), amount:0.40), ] : [ - NSColor(hex: 0xFFFFFF).mixed(with:accent, amount:0.07), - NSColor(hex: 0xECECEC).mixed(with:accent, amount:0.13), - NSColor(hex: 0xFAFAFA).mixed(with:accent, amount:0.09), + accent.mixed(with:NSColor(hex: 0xFFFFFF), amount:0.42), + accent.mixed(with:NSColor(hex: 0xE9E6ED), amount:0.30), + accent.mixed(with:NSColor(hex: 0xF7F5F8), amount:0.48), ]).map(\.cgColor) - let smoke = NSColor(hex: dark ? 0xFFFFFF : 0x111111) - let silver = NSColor(hex: dark ? 0xA8A8A8 : 0x8E8E8E) - glowA.colors = [smoke.withAlphaComponent(dark ? 0.12 : 0.09).cgColor, - smoke.withAlphaComponent(dark ? 0.045 : 0.032).cgColor, + let smoke = accent.mixed(with:NSColor.white, amount:dark ? 0.34 : 0.58) + let silver = accent.mixed(with:NSColor.black, amount:dark ? 0.08 : 0.22) + glowA.colors = [smoke.withAlphaComponent(dark ? 0.18 : 0.16).cgColor, + smoke.withAlphaComponent(dark ? 0.07 : 0.06).cgColor, smoke.withAlphaComponent(0).cgColor] - glowB.colors = [silver.withAlphaComponent(dark ? 0.10 : 0.08).cgColor, - silver.withAlphaComponent(dark ? 0.038 : 0.028).cgColor, + glowB.colors = [silver.withAlphaComponent(dark ? 0.14 : 0.12).cgColor, + silver.withAlphaComponent(dark ? 0.055 : 0.045).cgColor, silver.withAlphaComponent(0).cgColor] cardTitle.foregroundColor = (dark ? NSColor.white : NSColor(hex: 0x171717)).cgColor cardMark.contents = metaballSheets[0] diff --git a/captutor/bin/recompose-existing.mjs b/captutor/bin/recompose-existing.mjs index 6b7b1c9d5f..d7059b80e9 100755 --- a/captutor/bin/recompose-existing.mjs +++ b/captutor/bin/recompose-existing.mjs @@ -79,6 +79,7 @@ mux({ clip, beats:cues, out:composed, vtt }); deliver({ clip:composed, cues, format, out:output, workDir, locale, title:say(sp.title), + shortTitle:sp.shortTitle ? say(sp.shortTitle) : null, brandChrome:sp.brandChrome || null, chapters, terminalCard:null, diff --git a/captutor/captutor.mjs b/captutor/captutor.mjs index ac86345046..94a750107a 100644 --- a/captutor/captutor.mjs +++ b/captutor/captutor.mjs @@ -1006,6 +1006,7 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { const r = deliver({ clip:outMp4, cues:compositionTimed, format, out:burned, workDir, locale, title:sp.title ? say(sp.title, locale) : null, + shortTitle:sp.shortTitle ? say(sp.shortTitle, locale) : null, brandChrome:sp.brandChrome || null, chapters, terminalCard:null, @@ -1039,6 +1040,7 @@ async function cmdRender(sp, workDir, locale, format, attempt = 1) { theme, effectTheme:sp.effectTheme || null, title:sp.title ? say(sp.title, locale) : null, + shortTitle:sp.shortTitle ? say(sp.shortTitle, locale) : null, subtitle:sp.subtitle ? say(sp.subtitle, locale) : null, // QA receipts are operational documents, not localized deliverables. Keep // an English copy beside the filmed language so reviewers can always read @@ -1155,6 +1157,7 @@ async function cmdDeliver(sp, workDir, formats, locale) { clip, cues, format, out, workDir, locale, title: say(sp.title, locale), + shortTitle: sp.shortTitle ? say(sp.shortTitle, locale) : null, subtitle: say(sp.subtitle, locale), brandChrome:sp.brandChrome || null, chapters, diff --git a/captutor/lib/brand-chrome.mjs b/captutor/lib/brand-chrome.mjs index 9212f3770f..c0c62e1e4b 100644 --- a/captutor/lib/brand-chrome.mjs +++ b/captutor/lib/brand-chrome.mjs @@ -70,6 +70,7 @@ export function layoutBrandChrome(theme, { labelStrokeFraction: clamp(finite(merged.labelStrokeFraction, 0.03), 0, 0.08), labelShadow: merged.labelShadow !== false, sectionLabel: Boolean(merged.sectionLabel), + unifiedSectionStamp: Boolean(merged.unifiedSectionStamp), sectionLabelPx: Math.round(short * finite(merged.sectionLabelPxFraction, 0.014)), sectionLabelGapPx: Math.round(short * finite(merged.sectionLabelGapFraction, 0.018)), sectionLabelOpacity: clamp(finite(merged.sectionLabelOpacity, 0.84), 0.1, 1), @@ -433,6 +434,20 @@ export function separateBrandChromeFilter(layout, characterCount = 0, outputLabe ].join(";"); } +export function unifiedSectionStampBaseFilter(layout, outputLabel = "brandbase") { + const phase = "2*PI*t/" + layout.periodSec.toFixed(3); + const leftX = `${layout.edgePx}-overlay_w/2`; + const rightX = `main_w-${layout.edgePx}-overlay_w/2`; + const leftY = `main_h*${layout.leftMarkCenterY.toFixed(5)}-overlay_h/2+${layout.bobPx}*sin(${phase})`; + const rightY = `main_h*${layout.rightMarkCenterY.toFixed(5)}-overlay_h/2-${layout.bobPx}*sin(${phase})`; + return [ + `[1:v]format=rgba,colorchannelmixer=aa=${layout.opacity.toFixed(3)}[ml]`, + `[2:v]format=rgba,colorchannelmixer=aa=${layout.opacity.toFixed(3)}[mr]`, + `[0:v][ml]overlay=x='${leftX}+${layout.driftPx}*sin(${phase})':y='${leftY}':eval=frame:shortest=1[a]`, + `[a][mr]overlay=x='${rightX}-${layout.driftPx}*sin(${phase}+.6)':y='${rightY}':eval=frame:shortest=1[${outputLabel}]`, + ].join(";"); +} + function mixSectionColor(base, tint, amount = 0.46) { const parse = (value, fallback) => /^#[0-9a-f]{6}$/i.test(String(value || "")) ? [1, 3, 5].map((at) => parseInt(String(value).slice(at, at + 2), 16)) @@ -454,15 +469,20 @@ function renderSectionLabels(layout, { title, chapters, duration }, workDir) { const flat = join(workDir, `${stem}.png`); const left = join(workDir, `${stem}-left.png`); const right = join(workDir, `${stem}-right.png`); - const copy = `: ${title} · ${chapter.title || `Chapter ${index + 1}`}`; + const sectionTitle = chapter.shortTitle || chapter.title || `Chapter ${index + 1}`; + const copy = layout.unifiedSectionStamp + ? `${layout.label}: ${title} - ${sectionTitle}` + : `: ${title} · ${sectionTitle}`; const color = mixSectionColor( layout.labelColor, chapter.wallpaperColor || chapter.color || layout.labelColor, + layout.unifiedSectionStamp ? 0.78 : 0.46, ); execFileSync("magick", [ "-background", "none", "-fill", color, - "-font", layout.font, "-pointsize", String(layout.sectionLabelPx * 2), - "-weight", "600", `label:${copy}`, "-resize", "50%", flat, + "-font", layout.font, + "-pointsize", String((layout.unifiedSectionStamp ? layout.labelPx : layout.sectionLabelPx) * 2), + "-weight", String(layout.labelWeight), `label:${copy}`, "-resize", "50%", flat, ], { stdio:"pipe" }); execFileSync("magick", [flat, "-background", "none", "-rotate", "90", left], { stdio:"pipe" }); execFileSync("magick", [flat, "-background", "none", "-rotate", "-90", right], { stdio:"pipe" }); @@ -522,9 +542,12 @@ export function applyBrandChrome({ duration:media.duration, }, assetsDir) : []; + const unifiedSectionStamp = Boolean(layout.unifiedSectionStamp && sectionLabels.length); mkdirSync(dirname(out), { recursive: true }); const baseOverlayInputs = lockups.kind === "separate" - ? lockups.labelLeftChars?.length + ? unifiedSectionStamp + ? [lockups.markLeft, lockups.markRight] + : lockups.labelLeftChars?.length ? [lockups.markLeft, ...lockups.labelLeftChars, lockups.markRight, ...lockups.labelRightChars] : [lockups.markLeft, lockups.labelLeft, lockups.markRight, lockups.labelRight] : [lockups.left, lockups.right]; @@ -532,8 +555,9 @@ export function applyBrandChrome({ ...baseOverlayInputs, ...sectionLabels.flatMap((section) => [section.left, section.right]), ]; + const animatedMarkInputs = unifiedSectionStamp ? [0, 1] : [0, 2]; const ffmpegInputs = overlayInputs.flatMap((path, index) => - lockups.animatedMark && (index === 0 || index === 2) + lockups.animatedMark && animatedMarkInputs.includes(index) ? ["-stream_loop", "-1", "-i", path] : ["-loop", "1", "-i", path]); execFileSync(FFMPEG, [ @@ -541,11 +565,13 @@ export function applyBrandChrome({ "-filter_complex_threads", "1", "-filter_complex", lockups.kind === "separate" ? [ - separateBrandChromeFilter( - layout, - lockups.labelLeftChars?.length || 0, - sectionLabels.length ? "brandbase" : "outv", - ), + unifiedSectionStamp + ? unifiedSectionStampBaseFilter(layout, "brandbase") + : separateBrandChromeFilter( + layout, + lockups.labelLeftChars?.length || 0, + sectionLabels.length ? "brandbase" : "outv", + ), sectionLabelFilter(layout, sectionLabels, 1 + baseOverlayInputs.length, "brandbase"), ].filter(Boolean).join(";") : brandChromeFilter(layout), diff --git a/captutor/lib/deliver.mjs b/captutor/lib/deliver.mjs index 8c9adba768..6a77b9e41a 100644 --- a/captutor/lib/deliver.mjs +++ b/captutor/lib/deliver.mjs @@ -413,7 +413,7 @@ function renderTerminalCard({ clip, card, durationSec, width, height, workDir }) export function deliver({ clip, cues, format, out, workDir, locale = "en", brandChrome = null, geometry = null, captionPx = null, captionY = null, chapters = null, - terminalCard = null, title = null, + terminalCard = null, title = null, shortTitle = null, }) { FONT = fontFor(locale); // brand face for Latin, script-capable fallback otherwise const F = FORMATS[format]; @@ -586,11 +586,25 @@ export function deliver({ ); last = trackLabel; }); - const fillColor = chapterColor(ordered[0]?.color, "#a58cbc"); + chain.push( + `color=c=black@0.0:s=${W}x${bh}:r=${F.fps || 30}:d=${dur.toFixed(3)},` + + `format=rgba[chapter-fill-base]`, + ); + let fillStrip = "chapter-fill-base"; + ordered.forEach((chapter, index) => { + const x0 = index === 0 ? 0 : Math.round(W * chapter.startSec / dur); + const endSec = ordered[index + 1]?.startSec ?? dur; + const x1 = index === ordered.length - 1 ? W : Math.round(W * endSec / dur); + const fillLabel = `chapter-fill-${index}`; + chain.push( + `[${fillStrip}]drawbox=x=${x0}:y=0:w=${Math.max(1, x1 - x0)}:h=${bh}` + + `:color=${chapterColor(chapter.color, "#a58cbc")}@0.94:t=fill[${fillLabel}]`, + ); + fillStrip = fillLabel; + }); const progressLabel = "chapter-progressed"; chain.push( - `color=c=${fillColor}@0.92:s=${W}x${bh}:r=${F.fps || 30}:d=${dur.toFixed(3)},` + - `format=rgba,scale=w='max(1,iw*t/${dur.toFixed(3)})':h=ih:eval=frame[chapter-progress]`, + `[${fillStrip}]scale=w='max(1,iw*t/${dur.toFixed(3)})':h=ih:eval=frame[chapter-progress]`, `[${last}][chapter-progress]overlay=0:${H - bh}:eval=frame:shortest=1[${progressLabel}]`, ); last = progressLabel; @@ -664,7 +678,7 @@ export function deliver({ try { applyBrandChrome({ input:encodedOut, out, theme:brandChrome, workDir, format, - context:{ title, chapters:chapterList }, + context:{ title:shortTitle || title, chapters:chapterList }, }); } finally { if (existsSync(encodedOut)) unlinkSync(encodedOut); diff --git a/captutor/screenplays/apps-quickstart.mjs b/captutor/screenplays/apps-quickstart.mjs index c6e68d484a..34993057fd 100644 --- a/captutor/screenplays/apps-quickstart.mjs +++ b/captutor/screenplays/apps-quickstart.mjs @@ -128,6 +128,7 @@ export default { fps: 60, theme: "system", // follow Stage Mode's Light appearance; do not force product chrome title: "Build and publish your first app", + shortTitle: "First app", subtitle: "Fuser Apps · one prompt, a few clicks", effectTheme: fuserEffectTheme, brandChrome: learnFuserBrandChrome, @@ -137,15 +138,15 @@ export default { target:{ integrated:-16, truePeak:-1.5, lra:7 }, }, chapters: [ - { title:"Overview", startSec:0, beatIndex:0, color:"#a58cbc", trackColor:"#dedcdf", wallpaperColor:"#a58cbc" }, - { title:"Blank canvas", startSec:10.652, beatIndex:1, color:"#a58cbc", trackColor:"#d4d2d5", wallpaperColor:"#b4a7c7" }, - { title:"Add the App node", startSec:17.181, beatIndex:2, color:"#a58cbc", trackColor:"#dedcdf", wallpaperColor:"#9e8baa" }, - { title:"Focus and prompt", startSec:32.510, beatIndex:4, color:"#a58cbc", trackColor:"#d4d2d5", wallpaperColor:"#b9a7c2" }, - { title:"Free model", startSec:57.387, beatIndex:6, color:"#a58cbc", trackColor:"#dedcdf", wallpaperColor:"#a38ead" }, - { title:"Generate and preview", startSec:63.682, beatIndex:7, color:"#a58cbc", trackColor:"#d4d2d5", wallpaperColor:"#8e789c" }, - { title:"Refine", startSec:83.573, beatIndex:10, color:"#a58cbc", trackColor:"#dedcdf", wallpaperColor:"#b29dbb" }, - { title:"Publish and share", startSec:94.791, beatIndex:11, color:"#a58cbc", trackColor:"#d4d2d5", wallpaperColor:"#927f9c" }, - { title:"Finish", startSec:107.680, beatIndex:12, color:"#a58cbc", trackColor:"#dedcdf", wallpaperColor:"#a58cbc" }, + { title:"Overview", shortTitle:"Overview", startSec:0, beatIndex:0, color:"#b39ad0", trackColor:"#dedcdf", wallpaperColor:"#755a97" }, + { title:"Blank canvas", shortTitle:"Blank canvas", startSec:10.652, beatIndex:1, color:"#91aed1", trackColor:"#d4d2d5", wallpaperColor:"#56799f" }, + { title:"Add the App node", shortTitle:"Add App", startSec:17.181, beatIndex:2, color:"#89bab4", trackColor:"#dedcdf", wallpaperColor:"#4f8b85" }, + { title:"Focus and prompt", shortTitle:"Prompt", startSec:32.510, beatIndex:4, color:"#95bc9a", trackColor:"#d4d2d5", wallpaperColor:"#5f8c66" }, + { title:"Free model", shortTitle:"Free model", startSec:57.387, beatIndex:6, color:"#d1b77f", trackColor:"#dedcdf", wallpaperColor:"#9a7838" }, + { title:"Generate and preview", shortTitle:"Generate", startSec:63.682, beatIndex:7, color:"#d49b8f", trackColor:"#d4d2d5", wallpaperColor:"#a46054" }, + { title:"Refine", shortTitle:"Refine", startSec:83.573, beatIndex:10, color:"#c68fb0", trackColor:"#dedcdf", wallpaperColor:"#914f77" }, + { title:"Publish and share", shortTitle:"Publish", startSec:94.791, beatIndex:11, color:"#a794ca", trackColor:"#d4d2d5", wallpaperColor:"#6f5a98" }, + { title:"Finish", shortTitle:"Finish", startSec:107.680, beatIndex:12, color:"#b49bc9", trackColor:"#dedcdf", wallpaperColor:"#7d6593" }, ], openingCard: null, closingCard: { diff --git a/captutor/test/brand-chrome.test.mjs b/captutor/test/brand-chrome.test.mjs index 320c16ea89..f4a83ae799 100644 --- a/captutor/test/brand-chrome.test.mjs +++ b/captutor/test/brand-chrome.test.mjs @@ -1,7 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { brandChromeFilter, layoutBrandChrome, separateBrandChromeFilter } from "../lib/brand-chrome.mjs"; +import { + brandChromeFilter, + layoutBrandChrome, + separateBrandChromeFilter, + unifiedSectionStampBaseFilter, +} from "../lib/brand-chrome.mjs"; const theme = { id:"client-x", asset:"/tmp/client.svg", periodSec:20, @@ -97,17 +102,37 @@ test("animated client marks use real video frames without a perspective warp", ( font:"/tmp/client.ttf", labelStrokeFraction:0, labelShadow:false, - markOrientation:"upright", + markOrientation:"side", + driftPx:0, + bobPx:0, sectionLabel:true, + unifiedSectionStamp:true, sectionLabelPxFraction:0.013, }, { width:2560, height:1440, format:"docs", fps:"60/1" }); const filter = separateBrandChromeFilter(layout); assert.equal(layout.markVideoAsset, "/tmp/mark-spin.mov"); assert.equal(layout.labelStrokeFraction, 0); assert.equal(layout.labelShadow, false); - assert.equal(layout.markOrientation, "upright"); + assert.equal(layout.markOrientation, "side"); assert.equal(layout.sectionLabel, true); + assert.equal(layout.unifiedSectionStamp, true); assert.equal(layout.sectionLabelPx, 19); assert.equal(layout.markPerspective, null); + assert.equal(layout.driftPx, 0); + assert.equal(layout.bobPx, 0); assert.doesNotMatch(filter, /perspective=/); }); + +test("unified section stamps reserve the side for two marks and one dynamic line", () => { + const layout = layoutBrandChrome({ + id:"client-stamp", + markVideoAsset:"/tmp/mark-spin.mov", + label:"Learn Client", + font:"/tmp/client.ttf", + unifiedSectionStamp:true, + }, { width:2560, height:1440, format:"docs", fps:"60/1" }); + const filter = unifiedSectionStampBaseFilter(layout); + assert.match(filter, /\[1:v\].*\[ml\]/); + assert.match(filter, /\[2:v\].*\[mr\]/); + assert.doesNotMatch(filter, /\[3:v\]/); +}); diff --git a/captutor/test/tutorial-screenplays.test.mjs b/captutor/test/tutorial-screenplays.test.mjs index 405d2fb571..01bfa172ae 100644 --- a/captutor/test/tutorial-screenplays.test.mjs +++ b/captutor/test/tutorial-screenplays.test.mjs @@ -5,6 +5,7 @@ import basic from "../screenplays/basic-tutorial.mjs"; import imageGeneration from "../screenplays/image-generation-workflow.mjs"; import imageNode from "../screenplays/image-node-tutorial.mjs"; import editorSettings from "../screenplays/editor-settings-tour.mjs"; +import appsQuickstart from "../screenplays/apps-quickstart.mjs"; const LOCALES = ["en", "es", "fr"]; @@ -44,3 +45,15 @@ test("image-generation-workflow has complete English and Chinese narration", () assert.match(imageGeneration.beats.map((beat) => beat.say.en).join(" "), /image-to-image generation/i); assert.match(imageGeneration.beats.map((beat) => beat.say["zh-CN"]).join(" "), /图生图/); }); + +test("apps quickstart gives every chapter a distinct compact color identity", () => { + assert.equal(appsQuickstart.shortTitle, "First app"); + assert.equal(new Set(appsQuickstart.chapters.map((chapter) => chapter.color)).size, + appsQuickstart.chapters.length); + assert.equal(new Set(appsQuickstart.chapters.map((chapter) => chapter.wallpaperColor)).size, + appsQuickstart.chapters.length); + for (const chapter of appsQuickstart.chapters) { + assert.ok(chapter.shortTitle.length <= 12); + assert.notEqual(chapter.color, chapter.wallpaperColor); + } +}); diff --git a/captutor/themes/fuser.mjs b/captutor/themes/fuser.mjs index 9e842e5e12..1b218c6e2b 100644 --- a/captutor/themes/fuser.mjs +++ b/captutor/themes/fuser.mjs @@ -63,30 +63,35 @@ export const learnFuserBrandChrome = Object.freeze({ markAsset: join(HERE, "..", "assets", "fuser-metaballs-1.png"), markVideoAsset: join(HERE, "..", "assets", "fuser-metaballs-spin.mov"), markPreserveColor: true, - markOrientation: "upright", + markOrientation: "side", markPerspective: null, + driftFraction: 0, + driftPx: 0, + bobFraction: 0, + bobPx: 0, label: "Learn Fuser", labelAsset: null, labelAssetCrop: null, labelCharacterCuts: null, characterMotion: null, - leftMarkCenterY: 0.69, - leftLabelCenterY: 0.785, - rightMarkCenterY: 0.31, - rightLabelCenterY: 0.215, + leftMarkCenterY: 0.44, + leftLabelCenterY: 0.65, + rightMarkCenterY: 0.56, + rightLabelCenterY: 0.35, labelStrokeFraction: 0, labelShadow: false, labelColor: "#4f3d59", sectionLabel: true, - sectionLabelOpacity: 0.88, + unifiedSectionStamp: true, + sectionLabelOpacity: 0.96, formats: { ...fuserBrandChrome.formats, docs: { ...fuserBrandChrome.formats.docs, markSideFraction:0.052, labelPxFraction:0.021, - sectionLabelPxFraction:0.013, - sectionLabelGapFraction:0.019, + sectionLabelPxFraction:0.021, + sectionLabelGapFraction:0, }, }, }); -- 2.51.2