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
6 changes: 5 additions & 1 deletion apps/web/src/app/docs/api/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const ENDPOINTS: Endpoint[] = [
{ method: 'POST', path: '/api/dashboard', summary: 'Mutate dashboard state (pin, archive, etc.).' },
{ method: 'GET', path: '/api/training/status', summary: 'Current status of training/embedding jobs.' },
{ method: 'POST', path: '/api/training/trigger', summary: 'Trigger a training/embedding job.' },
{ method: 'POST', path: '/api/video/pack', summary: 'Emit a hashed Video Pack from a YouTube URL.', body: '{ "url": "https://www.youtube.com/watch?v=..." }' },
{ method: 'POST', path: '/api/workflows/video-to-actions', summary: 'Start the durable Studio analysis workflow.', body: '{ "url": "https://www.youtube.com/watch?v=..." }' },
];

const METHOD_COLOR: Record<Endpoint['method'], string> = {
Expand Down Expand Up @@ -104,7 +106,9 @@ export default function ApiDocsPage() {
<Link href="/studio" className="text-teal-400 hover:underline">
studio
</Link>{' '}
runs these endpoints end-to-end against a real YouTube URL.
runs the end-to-end YouTube workflow against a real URL: hashed Video
Pack via <code className="text-teal-300">/api/video/pack</code>, then{' '}
<code className="text-teal-300">/api/workflows/video-to-actions</code>.
</p>
</section>
</main>
Expand Down
26 changes: 14 additions & 12 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
studioStatusMessage,
} from '@/lib/studio-pipeline-status';
import { buildSameRunActInput, MIN_ACT_TRANSCRIPT_CHARS } from '@/lib/video-to-actions-input';
import { resolveStudioHandoff } from '@/lib/studio-handoff';
import { applyStudioQueryAutoStart, resolveStudioHandoff } from '@/lib/studio-handoff';
import { CANONICAL_STUDIO_PATH } from '@/lib/auth-paths';
import type { ExtractedEvent } from '@/lib/types';

Expand Down Expand Up @@ -192,17 +192,19 @@
};

useEffect(() => {
const q = searchParams.get('video') || searchParams.get('url');
if (!q) return;
const handoff = resolveStudioHandoff(q);
if (!handoff) {
setUrl(q);
return;
}
setUrl(handoff.watchUrl);
if (autoStartedKey.current === handoff.videoId) return;
autoStartedKey.current = handoff.videoId;
void runAnalysis(handoff.watchUrl);
applyStudioQueryAutoStart({
query: searchParams.get('video') || searchParams.get('url'),
startedKey: autoStartedKey,
start: (watchUrl) => {
void runAnalysis(watchUrl);
},
onResolved: (watchUrl) => {
setUrl(watchUrl);
},
onInvalidQuery: (raw) => {
setUrl(raw);
},
});
// One-shot kick from ?video= so Home paste starts the live pack path.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
Expand Down Expand Up @@ -281,7 +283,7 @@
const started = await startVideoToActions(payload);
if (!started.ok || !started.runId) {
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`;

Check warning on line 286 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 286 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
setMessage(started.error || started.message || 'Could not start Act.');
Expand Down Expand Up @@ -362,7 +364,7 @@
try {
const started = await startStudioDeploy({ url: next });
if (started.status === 401 || started.status === 403) {
window.location.href = `/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`;

Check warning on line 367 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / lint-frontend

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination

Check warning on line 367 in apps/web/src/components/OneLoopStudio.tsx

View workflow job for this annotation

GitHub Actions / build

Do not use `window.location.href` to navigate to internal Next.js pages. Use `redirect()` in the render phase, or `useRouter().push()` in Client Components' event handlers instead. See: https://nextjs.org/docs/messages/no-location-assign-relative-destination
return;
}
if (!started.ok || !started.runId) {
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/lib/__tests__/home-sell-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ describe('Home is a sell page; Studio is the workbench', () => {

it('keeps the live workbench only on /studio', () => {
const studio = readSource('app/studio/page.tsx');
const workbench = readSource('components/OneLoopStudio.tsx');
expect(studio).toContain('OneLoopStudio');
expect(readSource('components/OneLoopStudio.tsx')).toContain('resolveStudioHandoff');
expect(readSource('components/OneLoopStudio.tsx')).toContain('autoStartedKey');
expect(workbench).toContain('applyStudioQueryAutoStart');
expect(workbench).toContain('processVideo');
});

it('sends Home paste into /studio?video= after kicking pack emit', () => {
Expand Down Expand Up @@ -80,4 +81,12 @@ describe('Home is a sell page; Studio is the workbench', () => {
expect(pricing).toContain('turnstile');
expect(config).not.toMatch(/source: '\/pricing'/);
});

it('describes Studio as the YouTube workflow, not the REST catalog', () => {
const docs = readSource('app/docs/api/page.tsx');
expect(docs).not.toContain('runs these endpoints');
expect(docs).toContain('end-to-end YouTube workflow');
expect(docs).toContain('/api/video/pack');
expect(docs).toContain('/api/workflows/video-to-actions');
});
});
56 changes: 56 additions & 0 deletions apps/web/src/lib/__tests__/studio-handoff.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
applyStudioQueryAutoStart,
resolveStudioHandoff,
studioVideoHref,
submitHomePaste,
Expand Down Expand Up @@ -68,3 +69,58 @@ describe('submitHomePaste kicks pack emit then hands off to Studio', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});

describe('applyStudioQueryAutoStart (?video= one-shot, Strict Mode safe)', () => {
it('starts once with the canonical watch URL across a Strict Mode double effect', () => {
const startedKey = { current: null as string | null };
const start = vi.fn();
const onResolved = vi.fn();

const first = applyStudioQueryAutoStart({
query: FIXTURE_ID,
startedKey,
start,
onResolved,
});
const remount = applyStudioQueryAutoStart({
query: FIXTURE_ID,
startedKey,
start,
onResolved,
});
const sameQueryRerender = applyStudioQueryAutoStart({
query: FIXTURE_WATCH,
startedKey,
start,
onResolved,
});

expect(first).toBe('started');
expect(remount).toBe('already');
expect(sameQueryRerender).toBe('already');
expect(start).toHaveBeenCalledTimes(1);
expect(start).toHaveBeenCalledWith(FIXTURE_WATCH);
expect(onResolved).toHaveBeenCalledWith(FIXTURE_WATCH);
expect(startedKey.current).toBe(FIXTURE_ID);
});

it('does not start analysis for junk or empty query', () => {
const startedKey = { current: null as string | null };
const start = vi.fn();
const onInvalidQuery = vi.fn();

expect(
applyStudioQueryAutoStart({ query: null, startedKey, start, onInvalidQuery }),
).toBe('skipped');
expect(
applyStudioQueryAutoStart({
query: 'not a youtube url',
startedKey,
start,
onInvalidQuery,
}),
).toBe('invalid');
expect(start).not.toHaveBeenCalled();
expect(onInvalidQuery).toHaveBeenCalledWith('not a youtube url');
});
});
27 changes: 27 additions & 0 deletions apps/web/src/lib/studio-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,30 @@ export function submitHomePaste(raw: string): string | null {
startVideoPackEmit(handoff.watchUrl);
return handoff.href;
}

export type StudioQueryStartedKey = { current: string | null };

/**
* One-shot ?video= / ?url= kick. Safe under Strict Mode: the same startedKey
* ref suppresses a second start() for the same video id.
*/
export function applyStudioQueryAutoStart(input: {
query: string | null | undefined;
startedKey: StudioQueryStartedKey;
start: (watchUrl: string) => void;
onResolved?: (watchUrl: string) => void;
onInvalidQuery?: (raw: string) => void;
}): 'skipped' | 'invalid' | 'started' | 'already' {
const query = typeof input.query === 'string' ? input.query.trim() : '';
if (!query) return 'skipped';
const handoff = resolveStudioHandoff(query);
if (!handoff) {
input.onInvalidQuery?.(query);
return 'invalid';
}
input.onResolved?.(handoff.watchUrl);
if (input.startedKey.current === handoff.videoId) return 'already';
input.startedKey.current = handoff.videoId;
input.start(handoff.watchUrl);
return 'started';
}
Loading