Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e695cdf499 | |||
| 4ca93a6f16 | |||
| 2f1da79f9c | |||
| c59a912eef | |||
| 3d145379f2 | |||
| ae5c2d8821 | |||
| e455cd593c | |||
| 38ae9d7d07 | |||
| abb3912530 | |||
| 8b4ec2c4d4 | |||
| ced4b56dee | |||
| 95e34b8760 | |||
| c51d88fd19 | |||
| 4d1c9d9b1b | |||
| 90cfdfdf97 | |||
| f65a813768 | |||
| b5dc99e067 | |||
| c94bb6d3ed | |||
| 101fd952d5 | |||
| 2c85ea0b83 | |||
| 13afdd7a3b | |||
| 05f0e2db06 | |||
| e368145f9d | |||
| 0123f3db76 | |||
| d6cb68cf13 | |||
| 132bf7db35 | |||
| 1575afc92c |
@@ -24,8 +24,10 @@ beforeEach(() => {
|
||||
vi.mocked(api.createSession).mockResolvedValue({ authenticated: true });
|
||||
vi.mocked(api.deleteSession).mockResolvedValue(undefined);
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([]);
|
||||
vi.mocked(api.searchBranches).mockResolvedValue([]);
|
||||
vi.mocked(api.searchCommits).mockResolvedValue([]);
|
||||
vi.mocked(api.createDeployment).mockResolvedValue({
|
||||
id: 'preview-1',
|
||||
id: null,
|
||||
branch: 'master',
|
||||
status: 'queued',
|
||||
health: 'pending',
|
||||
@@ -33,7 +35,7 @@ beforeEach(() => {
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
});
|
||||
vi.mocked(api.uninstallDeployment).mockResolvedValue({
|
||||
id: 'preview-1',
|
||||
id: '1',
|
||||
branch: 'master',
|
||||
status: 'uninstalling',
|
||||
health: 'pending',
|
||||
@@ -81,11 +83,12 @@ test('submits a branch with an optional commit hash', async () => {
|
||||
test('shows health and web url, then confirms uninstall', async () => {
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([
|
||||
{
|
||||
id: 'preview-2',
|
||||
id: '42',
|
||||
branch: 'feature/demo',
|
||||
resolvedCommit: '1234567890abcdef',
|
||||
status: 'running',
|
||||
health: 'healthy',
|
||||
webPort: 8400,
|
||||
webUrl: 'http://192.168.35.82:8400',
|
||||
createdAt: 1_787_270_400,
|
||||
updatedAt: 1_787_270_460,
|
||||
@@ -94,6 +97,8 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
expect(await screen.findByText('健康')).toBeTruthy();
|
||||
expect(screen.getByText('端口 8400')).toBeTruthy();
|
||||
expect(screen.getByText('构建 #42')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'),
|
||||
).toBe('http://192.168.35.82:8400');
|
||||
@@ -103,6 +108,89 @@ test('shows health and web url, then confirms uninstall', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认卸载' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2');
|
||||
expect(api.uninstallDeployment).toHaveBeenCalledWith('42');
|
||||
});
|
||||
});
|
||||
|
||||
test('shows debounced branch results and selects one with the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.searchBranches).mockResolvedValue([
|
||||
{ name: 'feature/search-one', commitHash: '111111111111' },
|
||||
{ name: 'feature/search-two', commitHash: '222222222222' },
|
||||
]);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
|
||||
await user.clear(branchInput);
|
||||
await user.type(branchInput, 'feature/search');
|
||||
|
||||
expect(
|
||||
await screen.findByRole('option', { name: /feature\/search-one/u }),
|
||||
).toBeTruthy();
|
||||
expect(api.searchBranches).toHaveBeenLastCalledWith(
|
||||
'feature/search',
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
|
||||
expect((branchInput as HTMLInputElement).value).toBe('feature/search-two');
|
||||
});
|
||||
|
||||
test('searches commits in the current branch and selects a result', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.searchCommits).mockResolvedValue([
|
||||
{
|
||||
commitHash: 'aa5221abcdef0123456789',
|
||||
shortHash: 'aa5221a',
|
||||
subject: '补充预览搜索',
|
||||
},
|
||||
]);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const commitInput = await screen.findByRole('combobox', {
|
||||
name: 'Commit Hash',
|
||||
});
|
||||
await user.type(commitInput, 'aa5221a');
|
||||
|
||||
const option = await screen.findByRole('option', {
|
||||
name: /aa5221a.*补充预览搜索/u,
|
||||
});
|
||||
expect(api.searchCommits).toHaveBeenCalledWith(
|
||||
'master',
|
||||
'aa5221a',
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
await user.click(option);
|
||||
expect((commitInput as HTMLInputElement).value).toBe(
|
||||
'aa5221abcdef0123456789',
|
||||
);
|
||||
});
|
||||
|
||||
test('clears commit and ignores stale commit responses when branch changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined;
|
||||
vi.mocked(api.searchCommits).mockImplementation(
|
||||
() =>
|
||||
new Promise<api.CommitRef[]>((resolve) => {
|
||||
resolveOldSearch = resolve;
|
||||
}),
|
||||
);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
|
||||
const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' });
|
||||
await user.type(commitInput, 'abcdef1');
|
||||
await waitFor(() => expect(api.searchCommits).toHaveBeenCalled());
|
||||
await user.clear(branchInput);
|
||||
await user.type(branchInput, 'feature/new');
|
||||
|
||||
expect((commitInput as HTMLInputElement).value).toBe('');
|
||||
resolveOldSearch?.([
|
||||
{
|
||||
commitHash: 'abcdef1234567890',
|
||||
shortHash: 'abcdef1',
|
||||
subject: '旧分支提交',
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('旧分支提交')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -13,15 +13,27 @@ import {
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FormEvent,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
type BranchRef,
|
||||
type CommitRef,
|
||||
createDeployment,
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
listDeployments,
|
||||
PreviewDeployerApiError,
|
||||
searchBranches,
|
||||
searchCommits,
|
||||
uninstallDeployment,
|
||||
} from './api';
|
||||
import type {
|
||||
@@ -32,6 +44,7 @@ import type {
|
||||
import { validateBranch, validateCommitHash } from './validation';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const STATUS_LABELS: Record<DeploymentStatus, string> = {
|
||||
queued: '排队中',
|
||||
@@ -66,6 +79,16 @@ export function PreviewDeployerApp() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [branchSearch, setBranchSearch] =
|
||||
useState<SearchState<BranchRef>>(emptySearchState());
|
||||
const [commitSearch, setCommitSearch] =
|
||||
useState<SearchState<CommitRef>>(emptySearchState());
|
||||
const [branchActiveIndex, setBranchActiveIndex] = useState(-1);
|
||||
const [commitActiveIndex, setCommitActiveIndex] = useState(-1);
|
||||
const [branchSearchEnabled, setBranchSearchEnabled] = useState(false);
|
||||
const [commitSearchEnabled, setCommitSearchEnabled] = useState(false);
|
||||
const branchRequestId = useRef(0);
|
||||
const commitRequestId = useRef(0);
|
||||
const [pendingUninstall, setPendingUninstall] =
|
||||
useState<PreviewDeployment | null>(null);
|
||||
const [uninstallingId, setUninstallingId] = useState('');
|
||||
@@ -129,6 +152,94 @@ export function PreviewDeployerApp() {
|
||||
return () => window.clearInterval(interval);
|
||||
}, [refresh, sessionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = branch.trim();
|
||||
const requestId = ++branchRequestId.current;
|
||||
const controller = new AbortController();
|
||||
if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) {
|
||||
setBranchSearch(emptySearchState());
|
||||
setBranchActiveIndex(-1);
|
||||
return () => controller.abort();
|
||||
}
|
||||
setBranchSearch({ items: [], loading: true, open: true, error: '' });
|
||||
setBranchActiveIndex(-1);
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchBranches(query, controller.signal)
|
||||
.then((items) => {
|
||||
if (requestId !== branchRequestId.current) return;
|
||||
setBranchSearch({ items, loading: false, open: true, error: '' });
|
||||
})
|
||||
.catch((searchError) => {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
requestId !== branchRequestId.current
|
||||
)
|
||||
return;
|
||||
if (isUnauthorized(searchError)) {
|
||||
handleUnauthorized();
|
||||
return;
|
||||
}
|
||||
setBranchSearch({
|
||||
items: [],
|
||||
loading: false,
|
||||
open: true,
|
||||
error: formatError(searchError),
|
||||
});
|
||||
});
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [branch, branchSearchEnabled, sessionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = commitHash.trim();
|
||||
const selectedBranch = branch.trim();
|
||||
const requestId = ++commitRequestId.current;
|
||||
const controller = new AbortController();
|
||||
if (
|
||||
sessionStatus !== 'authenticated' ||
|
||||
!commitSearchEnabled ||
|
||||
!query ||
|
||||
validateBranch(selectedBranch)
|
||||
) {
|
||||
setCommitSearch(emptySearchState());
|
||||
setCommitActiveIndex(-1);
|
||||
return () => controller.abort();
|
||||
}
|
||||
setCommitSearch({ items: [], loading: true, open: true, error: '' });
|
||||
setCommitActiveIndex(-1);
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchCommits(selectedBranch, query, controller.signal)
|
||||
.then((items) => {
|
||||
if (requestId !== commitRequestId.current) return;
|
||||
setCommitSearch({ items, loading: false, open: true, error: '' });
|
||||
})
|
||||
.catch((searchError) => {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
requestId !== commitRequestId.current
|
||||
)
|
||||
return;
|
||||
if (isUnauthorized(searchError)) {
|
||||
handleUnauthorized();
|
||||
return;
|
||||
}
|
||||
setCommitSearch({
|
||||
items: [],
|
||||
loading: false,
|
||||
open: true,
|
||||
error: formatError(searchError),
|
||||
});
|
||||
});
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [branch, commitHash, commitSearchEnabled, sessionStatus]);
|
||||
|
||||
const runningCount = useMemo(
|
||||
() =>
|
||||
deployments.filter((deployment) => deployment.status === 'running')
|
||||
@@ -178,6 +289,9 @@ export function PreviewDeployerApp() {
|
||||
return;
|
||||
}
|
||||
const deployment = pendingUninstall;
|
||||
if (!deployment.id) {
|
||||
return;
|
||||
}
|
||||
setPendingUninstall(null);
|
||||
setUninstallingId(deployment.id);
|
||||
setError('');
|
||||
@@ -356,36 +470,166 @@ export function PreviewDeployerApp() {
|
||||
className="deploy-form"
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
>
|
||||
<label>
|
||||
<span>分支名</span>
|
||||
<div className="input-shell">
|
||||
<div className="search-field">
|
||||
<label htmlFor="branch-input">分支名</label>
|
||||
<div className="input-shell" role="presentation">
|
||||
<GitBranch size={17} />
|
||||
<input
|
||||
aria-activedescendant={
|
||||
branchSearch.open && branchActiveIndex >= 0
|
||||
? `branch-option-${branchActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="branch-search-list"
|
||||
aria-expanded={branchSearch.open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="分支名"
|
||||
role="combobox"
|
||||
id="branch-input"
|
||||
autoComplete="off"
|
||||
disabled={submitting}
|
||||
maxLength={200}
|
||||
placeholder="master"
|
||||
value={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setBranchSearchEnabled(true);
|
||||
setBranch(event.target.value);
|
||||
if (commitHash) {
|
||||
setCommitHash('');
|
||||
setCommitSearchEnabled(false);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
setBranchSearchEnabled(true);
|
||||
setBranchSearch((current) => ({ ...current, open: true }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
handleSearchKeyDown(
|
||||
event,
|
||||
branchSearch,
|
||||
branchActiveIndex,
|
||||
(index) => setBranchActiveIndex(index),
|
||||
(item) => {
|
||||
setBranch(item.name);
|
||||
setCommitHash('');
|
||||
setBranchSearchEnabled(false);
|
||||
setCommitSearchEnabled(false);
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
},
|
||||
() =>
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setBranchSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
120,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
<SearchResults
|
||||
id="branch-search-list"
|
||||
type="branch"
|
||||
state={branchSearch}
|
||||
onSelect={(item) => {
|
||||
setBranch(item.name);
|
||||
setCommitHash('');
|
||||
setBranchSearchEnabled(false);
|
||||
setCommitSearchEnabled(false);
|
||||
setBranchSearch((current) => ({ ...current, open: false }));
|
||||
}}
|
||||
activeIndex={branchActiveIndex}
|
||||
/>
|
||||
</div>
|
||||
<div className="search-field">
|
||||
<label htmlFor="commit-input">
|
||||
Commit Hash <small>可选</small>
|
||||
</span>
|
||||
<div className="input-shell">
|
||||
</label>
|
||||
<div className="input-shell" role="presentation">
|
||||
<Hash size={17} />
|
||||
<input
|
||||
aria-activedescendant={
|
||||
commitSearch.open && commitActiveIndex >= 0
|
||||
? `commit-option-${commitActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="commit-search-list"
|
||||
aria-expanded={commitSearch.open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Commit Hash"
|
||||
role="combobox"
|
||||
id="commit-input"
|
||||
autoComplete="off"
|
||||
disabled={submitting}
|
||||
maxLength={40}
|
||||
placeholder="留空则构建分支最新提交"
|
||||
value={commitHash}
|
||||
onChange={(event) => setCommitHash(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setCommitSearchEnabled(true);
|
||||
setCommitHash(event.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setCommitSearchEnabled(true);
|
||||
setCommitSearch((current) => ({ ...current, open: true }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
handleSearchKeyDown(
|
||||
event,
|
||||
commitSearch,
|
||||
commitActiveIndex,
|
||||
(index) => setCommitActiveIndex(index),
|
||||
(item) => {
|
||||
setCommitHash(item.commitHash);
|
||||
setCommitSearchEnabled(false);
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
},
|
||||
() =>
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
onBlur={() =>
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setCommitSearch((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
})),
|
||||
120,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<SearchResults
|
||||
id="commit-search-list"
|
||||
type="commit"
|
||||
state={commitSearch}
|
||||
onSelect={(item) => {
|
||||
setCommitHash(item.commitHash);
|
||||
setCommitSearchEnabled(false);
|
||||
setCommitSearch((current) => ({ ...current, open: false }));
|
||||
}}
|
||||
activeIndex={commitActiveIndex}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
@@ -435,7 +679,10 @@ export function PreviewDeployerApp() {
|
||||
<div className="deployment-list">
|
||||
{deployments.map((deployment) => (
|
||||
<DeploymentCard
|
||||
key={deployment.id}
|
||||
key={
|
||||
deployment.id ??
|
||||
`${deployment.branch}-${deployment.createdAt}`
|
||||
}
|
||||
deployment={deployment}
|
||||
uninstalling={uninstallingId === deployment.id}
|
||||
onUninstall={() => setPendingUninstall(deployment)}
|
||||
@@ -530,6 +777,12 @@ function DeploymentCard({
|
||||
<span className="health-dot" />
|
||||
{HEALTH_LABELS[deployment.health]}
|
||||
</span>
|
||||
{deployment.webPort ? (
|
||||
<span className="badge port-badge">端口 {deployment.webPort}</span>
|
||||
) : null}
|
||||
<span className="badge record-badge">
|
||||
构建 #{deployment.id || '待分配'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -567,7 +820,7 @@ function DeploymentCard({
|
||||
构建详情 <ExternalLink size={13} />
|
||||
</a>
|
||||
) : null}
|
||||
{canUninstall ? (
|
||||
{canUninstall && deployment.id ? (
|
||||
<button
|
||||
className="icon-danger-button"
|
||||
type="button"
|
||||
@@ -620,6 +873,121 @@ function Summary({
|
||||
);
|
||||
}
|
||||
|
||||
type SearchState<T> = {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function emptySearchState<T>(): SearchState<T> {
|
||||
return { items: [], loading: false, open: false, error: '' };
|
||||
}
|
||||
|
||||
function handleSearchKeyDown<T>(
|
||||
event: ReactKeyboardEvent<HTMLInputElement>,
|
||||
state: SearchState<T>,
|
||||
activeIndex: number,
|
||||
setActiveIndex: (index: number) => void,
|
||||
onSelect: (item: T) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
if (!state.open || (!state.items.length && event.key !== 'Escape')) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActiveIndex((activeIndex + 1) % state.items.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(activeIndex <= 0 ? state.items.length - 1 : activeIndex - 1);
|
||||
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const item = state.items[activeIndex];
|
||||
if (item) onSelect(item);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(-1);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function SearchResults({
|
||||
id,
|
||||
type,
|
||||
state,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
}:
|
||||
| {
|
||||
id: string;
|
||||
type: 'branch';
|
||||
state: SearchState<BranchRef>;
|
||||
activeIndex: number;
|
||||
onSelect: (item: BranchRef) => void;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'commit';
|
||||
state: SearchState<CommitRef>;
|
||||
activeIndex: number;
|
||||
onSelect: (item: CommitRef) => void;
|
||||
}) {
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<div className="search-results" id={id} role="listbox">
|
||||
{state.loading ? (
|
||||
<div className="search-state">
|
||||
<LoaderCircle className="spin" size={15} />
|
||||
正在搜索
|
||||
</div>
|
||||
) : state.error ? (
|
||||
<div className="search-state search-state-error">{state.error}</div>
|
||||
) : state.items.length === 0 ? (
|
||||
<div className="search-state">没有匹配结果</div>
|
||||
) : type === 'branch' ? (
|
||||
state.items.map((item, index) => (
|
||||
<button
|
||||
aria-selected={index === activeIndex}
|
||||
className={`search-option${index === activeIndex ? ' active' : ''}`}
|
||||
id={`branch-option-${index}`}
|
||||
key={item.name}
|
||||
role="option"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<GitBranch size={15} />
|
||||
<span>{item.name}</span>
|
||||
{item.commitHash ? (
|
||||
<small>{shortCommit(item.commitHash)}</small>
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
state.items.map((item, index) => (
|
||||
<button
|
||||
aria-selected={index === activeIndex}
|
||||
className={`search-option${index === activeIndex ? ' active' : ''}`}
|
||||
id={`commit-option-${index}`}
|
||||
key={item.commitHash}
|
||||
role="option"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<Hash size={15} />
|
||||
<span>
|
||||
<strong>{item.shortHash || shortCommit(item.commitHash)}</strong>
|
||||
{item.subject ? <small>{item.subject}</small> : null}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isActive(status: DeploymentStatus) {
|
||||
return ['queued', 'building', 'deploying', 'uninstalling'].includes(status);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
deleteSession,
|
||||
getSession,
|
||||
listDeployments,
|
||||
searchBranches,
|
||||
searchCommits,
|
||||
uninstallDeployment,
|
||||
} from './api';
|
||||
|
||||
@@ -112,3 +114,45 @@ test('submits only branch and optional commit to fixed endpoints', async () => {
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('searches branches and branch-scoped commits with encoded queries', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [{ name: 'feature/search', commitHash: 'abcdef123456' }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
commitHash: 'abcdef123456',
|
||||
shortHash: 'abcdef1',
|
||||
subject: '搜索提交',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
expect(await searchBranches('feature/search')).toHaveLength(1);
|
||||
expect(await searchCommits('feature/search', 'abc def')).toHaveLength(1);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/preview-deployer/refs/branches?q=feature%2Fsearch',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/preview-deployer/refs/commits?branch=feature%2Fsearch&q=abc%20def',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,17 @@ export interface PreviewDeployerSession {
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export interface BranchRef {
|
||||
name: string;
|
||||
commitHash?: string | null;
|
||||
}
|
||||
|
||||
export interface CommitRef {
|
||||
commitHash: string;
|
||||
shortHash: string;
|
||||
subject?: string | null;
|
||||
}
|
||||
|
||||
export async function getSession(signal?: AbortSignal) {
|
||||
const session = await request<PreviewDeployerSession | null>('/session', {
|
||||
signal,
|
||||
@@ -41,6 +52,26 @@ export async function listDeployments(signal?: AbortSignal) {
|
||||
return Array.isArray(payload) ? payload : payload.deployments;
|
||||
}
|
||||
|
||||
export async function searchBranches(query: string, signal?: AbortSignal) {
|
||||
const payload = await request<BranchRef[] | { items: BranchRef[] }>(
|
||||
`/refs/branches?q=${encodeURIComponent(query)}`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload) ? payload : payload.items;
|
||||
}
|
||||
|
||||
export async function searchCommits(
|
||||
branch: string,
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const payload = await request<CommitRef[] | { items: CommitRef[] }>(
|
||||
`/refs/commits?branch=${encodeURIComponent(branch)}&q=${encodeURIComponent(query)}`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload) ? payload : payload.items;
|
||||
}
|
||||
|
||||
export function createDeployment(input: CreateDeploymentInput) {
|
||||
return request<PreviewDeployment>('/deployments', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -190,14 +190,77 @@ a {
|
||||
padding: 22px;
|
||||
box-shadow: 0 12px 32px rgba(28, 37, 51, 0.055);
|
||||
}
|
||||
.deploy-form label {
|
||||
.deploy-form label,
|
||||
.search-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #39465a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.deploy-form label small {
|
||||
.search-field {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-results {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% - 1px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #cfd7e3;
|
||||
border-radius: 0 0 9px 9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 25px rgba(28, 37, 51, 0.13);
|
||||
}
|
||||
.search-option {
|
||||
display: flex;
|
||||
min-height: 39px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
color: #344054;
|
||||
background: #fff;
|
||||
padding: 7px 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.search-option:hover,
|
||||
.search-option.active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
.search-option > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-option small {
|
||||
overflow: hidden;
|
||||
color: #8792a2;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-state {
|
||||
display: flex;
|
||||
min-height: 39px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #8792a2;
|
||||
padding: 0 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-state-error {
|
||||
color: #b42318;
|
||||
}
|
||||
.deploy-form label small,
|
||||
.search-field label small {
|
||||
color: #8994a5;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -381,6 +444,16 @@ a {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.port-badge {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.record-badge {
|
||||
color: #475569;
|
||||
background: #f8fafc;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.badge-running {
|
||||
color: #14734a;
|
||||
background: #e9f9f0;
|
||||
|
||||
@@ -11,12 +11,13 @@ export type DeploymentStatus =
|
||||
export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
|
||||
|
||||
export interface PreviewDeployment {
|
||||
id: string;
|
||||
id?: string | null;
|
||||
branch: string;
|
||||
commitHash?: string | null;
|
||||
resolvedCommit?: string | null;
|
||||
status: DeploymentStatus;
|
||||
health: DeploymentHealth;
|
||||
webPort?: number | null;
|
||||
webUrl?: string | null;
|
||||
jenkinsBuildUrl?: string | null;
|
||||
createdAt: string | number;
|
||||
|
||||
+6
@@ -1,8 +1,14 @@
|
||||
# 仅部署在本机内网 HTTP 入口 http://192.168.35.82/build/。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_BIND=127.0.0.1:8410
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL=http://127.0.0.1:8080/jenkins/
|
||||
# 页面“构建详情”链接使用局域网地址,内部请求仍使用上面的 loopback 地址。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
|
||||
# 只读源码查询固定使用本机 Gitea SSH 入口,控制服务不接受客户端传入 remote。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git
|
||||
# 使用 Jenkins 用户专用只读 deploy key 与独立 known_hosts;禁止关闭主机密钥校验。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND=ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令>
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md)
|
||||
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md)
|
||||
- [图片画布游戏场景生成链路](./technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md)
|
||||
- [Art Agent 多模态 Spine 序列帧动画生成](./technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md)
|
||||
- [画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md)
|
||||
- [SFX 生成优化 V2.0 任务拆解](./project-memory/plans/【实施计划】SFX生成优化V2.0任务拆解-2026-08-06.md)
|
||||
- [SFX 生成优化 V2.0 T6 测试与发布门禁](./【实施记录】SFX生成优化V2.0T6测试与发布门禁-2026-08-07.md)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-15 Spine 序列帧去背景复用父队列并行处理
|
||||
|
||||
- 背景:Spine 序列帧去背景已经是 `editor_character_animation_background_removal` durable job,但父 job 内的 33 帧仍逐帧串行下载、绿幕处理、上传和帧对象准备,用户只能看到长时间的二态 loading。
|
||||
- 决策:继续只使用一个现有 `external_generation_job` 父任务,不新增逐帧子任务、表、计费或公开 DTO;worker 在父 job 内用 `buffer_unordered(frame_count.max(1))` 并行处理所有帧,必须 drain 全部已发出的 future,再按 `frame_index` 排序并一次性发布。任一帧失败时清理已成功但尚未正式提交的对象;当前帧上传后帧对象准备失败时在该 future 内立即清理,避免并行化引入孤儿对象。
|
||||
- 幂等边界:帧对象路径绑定本次 job 的 `operation_id`,同一 job 重放复用路径,不同 job 即使输入 fingerprint 相同也必须生成新的对象路径和新的 asset_object;结果作为新的 `character-animation` / `image-sequence` resource、素材和画布图层发布,原始序列保留。
|
||||
- 去背景算法边界不变:只使用本地 `screen-color-keying` 绿幕扣除,不调用 LLM、BgFilter 或阿里云;普通图片去背景链路不受影响。
|
||||
- 关联文档:`docs/technical/【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md`。
|
||||
|
||||
## 2026-08-12 Repository checks 采用 CI 与本地共用的单一门禁入口
|
||||
|
||||
- 背景:master run 1037 的 Backend/Frontend 已通过,但 `Repository checks` 因 3 个 `simple-import-sort/imports` 错误失败。原 pre-commit 只运行 Prettier,Prettier 不处理 ESLint import 排序;推送前又未运行完整仓库 lint,因此本地与 CI 的覆盖范围长期存在漂移。
|
||||
@@ -16,7 +24,6 @@
|
||||
- 验证方式:覆盖 Codex 分类与敏感诱饵、失败事件公共摘要、最近任务与各正式卡片、game-chat 阶段记录、待核对状态、final-reply fallback 白名单及 malformed 响应完整重试;运行 Rust 定向测试、前端模型/AppSurface 定向测试、Shell typecheck、编码和 diff 门禁。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
|
||||
## 2026-08-10 资源管理评审阻塞项按第二轮正式合同修复
|
||||
|
||||
- 背景:资源管理第一轮实现后,人工验证继续暴露 WebView 默认缩放、预览队列饥饿、过滤后媒体残留播放、外层滚动串 scope、超深依赖坐标越过 Rust 上限和暂时错误无法重试等问题。部分 PRD / 技术方案仍描述第一轮的中央媒体预览、单全局 Overlay 和统一 section scope,已经与第二轮代码及验收结论冲突。
|
||||
@@ -60,6 +67,7 @@
|
||||
- 替代关系:本条替代下方 2026-08-07 阶段二中“缩放只等于可视高度”和“只为完整可见卡片建立端点”的显示口径;其中曾采用的全局 SVG、四 viewport 联合 clip 和单全局 Observer 又由上方“依赖 SVG 改为分区 plane 所有”决定替代。高度模型、四分区、会话隔离、内外滚动和无布局 CAS 等其它决定继续有效。阶段一媒体卡、阶段三确定性聚类、Rust `dependencyDepths`、producer 截断降级、历史手动坐标与 type sidecar 均不变。
|
||||
- 验证方式:纯倍率模型覆盖按钮 / wheel 边界;AppSurface 覆盖项目、mode、section 隔离、普通 wheel、Ctrl wheel、WebKit gesture 与零布局写入;SVG 覆盖同 plane 倍率、橙色 marker、直线 / 小圆角横纵路由、自环间隙、双向边界继续线、分区原生裁剪、每区单 Observer、task-flow 零渲染和 4096 精确关系有界输出。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-11 Tauri 无限画布以可证明事务和分类恢复收口
|
||||
|
||||
- 终态与队列:资源编辑账本正式区分可继续阶段、`reconciliation-required`、`remote-failed` 和 `archived`。远端明确失败只保存稳定分类与终态时间,不得再 POST、轮询或重新扣费;只有该终态能由用户显式归档并移出活动恢复队列,归档保留账本且不伪装 `committed`。结果未知和需对账项继续失败关闭。
|
||||
@@ -7184,7 +7192,6 @@
|
||||
- 决策:只有同一可信根 Supervisor 能调用 `agent.acceptance_update`,且该控制面动作必须独占一轮,不能夹带 `plan_update`、legacy plan 或回复。requiredEvidence 采用 `tool:<Runtime 工具名>`,合同冻结前必须命中 Runtime 允许的持久证据工具集合并拒绝拼错、控制面和纯协调工具;动态 MCP catalog 不得冻结为不可变 requiredEvidence。passed 节点必须引用当前根任务树中对应工具的真实成功动作回执;回执同时记录动作执行边界的 `projectRevisionBefore / projectRevisionAfter`,非 mutation evidence 必须满足 before、after 与验收时 current revision 完全相同,mutation evidence 的 after 必须等于 current revision,旧 revision 或执行后延迟落账的回执不可重放。failed、not-observed、缺失节点以及落后当前 project revision 的整图确认状态均阻断普通完成、finalization 与恢复。项目 revision 变化后由 Supervisor 只更新受影响节点并确认当前图,未提交的 passed 节点保持不变;Runtime 不替 Supervisor 推断影响范围、选择具体 Agent 或实现方式。
|
||||
- 决策:不可变 Goal Contract 的根 Run 收到 steer 时,必须按旧 rootRunId 串行化整个转换,并在持锁后重新确认该旧根仍是 Session 当前权威 Run,避免不同 steerId 并发创建多个 replacement。随后为绑定旧 rootRunId 的全部非终态静态、ready 和 isolated 后代写入取消栅栏、打断 Provider 并逐个收束,再终止旧根;所有项目修改入口在看到取消栅栏后立即失败关闭。Runtime 必须确认旧树所有成员都已进入终态或 `needs-reconciliation`,超时则保持等待并拒绝启动 replacement;只有旧树停稳后,才在同一 Session、source 和 Run Profile 启动新根 Run 重新理解完整目标。
|
||||
|
||||
|
||||
## 2026-08-08 External v1 图片编辑来源字段允许原地收紧
|
||||
|
||||
- 背景:图片编辑主来源已经从可由客户端提交 objectKey 和类型提示的 `sourceImageSrc / sourceResourceId / assetKind`,收紧为服务端按项目资源 ID 或素材 ID 解析权威对象与类型的必填 `sourceReferenceId`。这会让严格 External v1 客户端立即失败,属于现役版本策略明确列出的 breaking change;2026-07-31 的历史豁免不能自动覆盖本次变更。
|
||||
@@ -8653,6 +8660,7 @@
|
||||
- 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。
|
||||
|
||||
> 2026-07-18 覆盖说明:本段后续关于微信 `navigation.openNativePage`、生成结果订阅页、`[subscribe-message]` 日志和订阅页路由门禁的 2026-06 决策均已由旧创作模板退役决策废止,只作为历史记录。Expo / Tauri 的同源 H5 受控导航及微信登录、支付、分享能力继续有效。
|
||||
|
||||
- 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。
|
||||
- 2026-06-20 微信受控原生页能力声明:微信小程序壳真实 capability profile 声明 `navigation.openNativePage`,用于承接已经登记并测试的小程序原生页 flow;当前订阅生成结果通知页通过 H5 `requestGenerationResultSubscribePermission()` 调用 `navigateHostNativePage()` 打开 `/pages/subscribe-message/index`,小程序页再调用真实 `wx.requestSubscribeMessage` 并按既有结果协议回灌。根级 `npm run check:native-shells` 必须把该能力反查到共享 profile、微信 `WECHAT_HOST_CAPABILITIES` 镜像、订阅页协议常量、H5 入口、小程序 host-bridge / shell / page 文件和相关测试;该能力不代表开放任意小程序页面跳转。
|
||||
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示受控分享动作,并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达,避免旧壳或裁剪壳露出不可用入口。
|
||||
@@ -14067,6 +14075,7 @@
|
||||
- 分类:内部稳定 reason code 固定为 `translation_invalid / translation_upstream_failed / translation_budget_exhausted / elevenlabs_http_failed / invalid_audio / duration_probe_failed / oss_failed / writeback_failed`。MIME、空 body 和大小归 `invalid_audio`;MP3 识别、帧读取和时长门禁归 `duration_probe_failed`。普通用户继续只读稳定短文案,不暴露 endpoint、上游正文或凭据。
|
||||
- 跨入口:画布 Agent `generate-sound-effect` 与站内 / External v1 共用 canonical Prompt、固定模型、nullable `0.5-30` 小数时长和 Loop;省略 duration 为手动 `5s`,显式 null 为自动。SFX 参数解析必须保留该 null,不能被通用 null-default 兼容层改写。最终仍进入相同 `editor_sound_effect_generation` queue payload,不新增 Agent 专属链路。
|
||||
- 发布边界:T6 工程实施和 mock / loopback 门禁不等于真实 provider 或生产验收。发布前关闭 SFX 入队,使用显式 `--server` / `--server-url` 只读查询 `external_generation_job` 中 pending / running 的 `editor_sound_effect_generation`,清零后按 api-server / Worker → Web 顺序部署并灰度;禁止 `--root-dir`、删除任务伪造 drain 或自动回退 Vidu。本次没有 SpacetimeDB schema、migration 或 bindings 变更。
|
||||
|
||||
## 2026-08-06 编辑器生成结果使用 durable receipt 与统一原子提交
|
||||
|
||||
- 背景:图片、改图、去背景、图集 / UI 多产物、角色动作、视频、音效和背景音乐在 OSS 结果可用后,仍分段 confirm object、创建 project resource / account asset、保存 canvas 和 complete job。任一中间失败都会留下部分业务事实;只把 `external_generation_job` 当 operation journal 又无法覆盖无 job 的 inline,也无法独立证明某批 resource/asset/canvas 已作为一笔提交完成。
|
||||
@@ -14153,6 +14162,16 @@
|
||||
- Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。
|
||||
- Provider Schema 决策:`agent.route_manifest.missingAssetSlots` 不再广告 OpenAI-compatible 代理拒绝的 `uniqueItems`;Runtime 继续排序去重,Schema 子集门禁新增该关键字,真实 Provider smoke 必须在发布前证明工具目录可被接受。
|
||||
- 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。
|
||||
|
||||
## 2026-08-13 Spine 序列帧多模态与视频直接转换
|
||||
|
||||
- 同一 Spine 序列帧面板固定分为“AI生成/改造”和“视频直接转换”两个标签,旧项目默认恢复 AI。AI 视频以 `motion` 表示动作视频、`appearance` 表示可选角色外观图;上传序列帧 PNG 只作为一张完整参考图,不做网格识别。
|
||||
- 视频直接转换走内部 `editor_character_animation_video_conversion` durable job,FFprobe + FFmpeg 覆盖完整源时长,采样率最高 8 FPS、最低 1 FPS,产出 2–66 帧;480p/720p 是只缩不放的最大长边。该路径固定免费、保留背景,不调用 Seedance 或 BgFilter,也不复制保存源视频。
|
||||
- 直接转换仍落 `character-animation` / `image-sequence`,配方 action 为 `character-animation.convert`,来源槽位为 `source`;最多 66 帧时首帧 item 同时承载最终 resource/asset。原子生成上限和角色动画拆帧上限沿用 master 的 66,但图标图集公开切片继续保持 64,既有 JSON 大小门禁不放宽。
|
||||
- merge master 后保留角色图层浮动工具栏和右键菜单中的 `生成动画` 快捷入口,统一进入同一个角色动作 dialog;`character-animation` 结果不支持快速编辑,仍保留 `改造 / 去背景 / 拆帧 / 下载`。快速编辑入口与提交门禁使用统一正向白名单,未知媒体或素材类型默认拒绝。
|
||||
- Spine 序列帧工具栏的“去背景”暂定为纯算法绿幕扣除:只读取正式序列帧 objectKey,使用本地 `screen-color-keying` 处理约定的 `#00FF00` 绿幕,不调用 BgFilter、阿里云或其它模型;普通图片 `/api/editor/images/background-removals` 的通用去背景链路不受影响。
|
||||
- `/api/external/v1` 不开放上述内部字段或直接转换路由,不修改 OpenAPI;本次不改 SpacetimeDB 表结构、迁移或生成 bindings。
|
||||
|
||||
## 2026-08-15 Jenkins 容器预览部署使用独立控制面
|
||||
|
||||
- 决策:多人内网容器预览不把操作表单塞进 Jenkins 页面,也不让 SPA 直接操作 Docker。独立 `preview-deployer` SPA 通过同源 Axum 代理触发固定 `shared/Genarrative-Preview-Deployer` Job;浏览器只持有控制面 HttpOnly 会话,Jenkins service account 和 API Token 只存在服务端环境。
|
||||
@@ -14161,3 +14180,22 @@
|
||||
- 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。
|
||||
- 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。
|
||||
- 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 2026-08-17 Spine 视频模式外观参考图容量门禁
|
||||
|
||||
- 背景:视频模式的 `appearanceReferenceSrcs` 原先会在 Provider 调用前逐项下载并完整解码,但该实际执行路径没有复用通用视频参考的 9 张数量上限,也没有单图、累计字节、解码边长和像素预算,可能让第 10 张或压缩炸弹图片持续占用 durable worker 资源。
|
||||
- 决策:角色外观图最多 9 张,单图最大 30MB、合计最大 64MB,编码边长最大 8192、单图总像素最大 33554432。前端上传追加与草稿恢复收口到 9 张;HTTP 入队和 worker 共用请求归一化数量门禁;worker 在 Provider 调用前按 OSS HEAD、流式下载字节上限、解码器资源限制及显式像素计算失败关闭。既有 owner、稳定 objectKey、MIME 和来源资源校验继续保留。
|
||||
- 验证:覆盖 9/10 张边界、30MB 单图字节边界、8192 边长及总像素边界,并运行角色动作 Rust 定向测试、相关前端 Vitest、类型检查、编码与 diff 门禁。
|
||||
|
||||
## 2026-08-17 Spine 视频转换去背景改为显式双模式
|
||||
|
||||
- 背景:仅凭 `generationInputs.action = character-animation.convert` 无法证明源视频是绿幕;把任意转换结果固定按 `#00FF00` 键控会误处理普通背景或绿色主体。仓库已有 BgFilter `complex + birefnet` 通用分割服务,也已有可传 `screen_color` 的 flat 协议,但纯色场景不需要承担外部服务成本。
|
||||
- 决策:只有视频直接转换序列继续显示“去背景”,点击后必须在当前选中序列帧旁的非模态悬浮窗中显式选择 `solid-color` 或 `general`;悬浮窗随画布平移和缩放更新位置,优先置于右侧并在空间不足时翻转,不增加画布遮罩、不覆盖源画面,纯色模式不使用系统吸管;内置逐帧 viewer 从正式 objectKey 读取原始帧字节,按原始比例实际绘制,且仅在可见图像区域内采样。用户悬停时实时显示原始像素色样与 11×11 放大网格,点击才回填键色,以消除容器留白和 CSS 缩放导致的取色位移。纯色模式默认 `#00FF00`,允许颜色输入及逐帧 viewer 原始像素取样;后端使用参数化本地键色算法,对轻微压缩噪声和接近键色的平缓色差有容忍度,但不承诺处理明显渐变、阴影、纹理或与主体相近的背景,后者应选择 `general`。通用模式逐帧调用 BgFilter complex,不携带键色。悬浮窗的“提交中”只表示 HTTP 入队请求,服务端接受任务后立即关闭悬浮窗,由画布生成占位和任务列表承接 durable worker 的后续状态;HTTP 提交失败则保留悬浮窗并恢复可重试状态。HTTP 入队和 durable worker 共用白名单校验,后端在结果 `generationInputs` 中权威记录模式、颜色或 Provider/model,不能再把“视频转换”本身当作绿幕证明。两种模式仍生成新序列资源、整批失败关闭并保留源序列。
|
||||
- 费用边界:本次沿用现有手动 BgFilter 的 0 泥点内部契约;供应商成本不等于已确定的用户泥点售价,在没有正式定价决策时不自行新增价格。若后续收费,必须接入统一后端定价、入队冻结价格和 worker 扣费退款链路。
|
||||
- 验证:覆盖缺失/非法纯色、通用模式禁止颜色、任意键色透传、绿色主体在非绿色键色下保留、双模式 worker 路由、前端模式选择及吸管回填,并运行角色动作 Rust 定向测试、前端 Vitest、类型检查、编码和 diff 门禁。
|
||||
|
||||
## 2026-08-17 预览发布记录使用 Jenkins 构建编号并有限保留
|
||||
|
||||
- 决策:内部稳定 `deploymentId` 继续绑定分支、Compose project 和端口租约;页面/API 记录 ID 在 Jenkins 分配执行器后改为构建编号,排队阶段为“待分配”。卸载通过构建编号找到内部实例,再向固定 Job 传内部 ID。
|
||||
- 清理:失败或取消且不存在可卸载实例的记录保留 7 天;成功卸载的内部审计记录保留 30 天;仍可卸载的失败记录永久保留到人工卸载。服务启动、读取列表和创建部署时执行清理并原子落盘。
|
||||
- 链接:服务内部仍通过 Jenkins loopback 轮询;只向浏览器返回由 `GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL` 构造的局域网构建详情地址,禁止回传 loopback URL。
|
||||
@@ -583,6 +583,8 @@
|
||||
- 现象:产品要求画板 `生成角色动作` 返回后按透明序列帧播放和下载,但旧实现或旧测试可能继续把结果当作预览视频处理。
|
||||
- 原因:后端仍需要先生成 `previewVideoPath` 再抽帧、绿幕去背和落 OSS;如果前端把预览视频当主媒体,就会绕过已经扣绿幕的 PNG 帧,也无法按序列帧打包下载。
|
||||
- 处理:角色动作结果图层主 `src` 使用 `frames[0].imageSrc`,`mediaType` 固定为 `image-sequence`,`assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames`,`previewVideoPath` 只作为来源信息保留。生成端确认每帧对象后必须把该帧 `objectKey` 与 `assetObjectId` 一起写入正式 payload 和 `generation_inputs_json.characterAnimation.frames`。单图层下载必须生成序列帧 ZIP;画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
|
||||
- 拆帧:拆帧只把正式帧对象登记成独立项目资源和账号素材,必须复用每帧已有的 `assetObjectId/objectKey`,禁止重新下载再上传 PNG。批次继续复用现有 spritesheet slice 批量事务,原子写入全部帧并完成 cohort;拆帧 task/cohort 必须按 owner + `sourceResourceId` + 规范化目标目录与标签稳定隔离,不能直接复用原生成 task,否则同一素材进入多个项目后会把多套帧归入一个错误 cohort。原帧 `asset_object.source_job_id` 继续保留原生成来源,不能为了匹配拆帧 task 伪造血缘。结果只进入素材库,不自动创建几十个画布图层。
|
||||
- 多模态参考:`inputMode/referenceMediaType` 只是客户端意图,不是媒体事实。图片或视频参考必须先按 owner 解析到权威 objectKey,再在扣费和 provider 调用前使用 OSS HEAD、权威 `asset_object.content_type` 或实际媒体探测验证 `image/*` / `video/*`;不能把上游报错和退款当作后端媒体校验。
|
||||
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录;生成测试还应断言首帧与非首帧的稳定引用都被保留。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
@@ -4248,6 +4250,13 @@
|
||||
- 处理:资源 / 素材只保存 `image_sequence_frames_json` 与 `image_sequence_duration_ms`,精选审核快照只冻结复制这两个正式字段。角色动作要求至少两帧且毫秒时长大于 0;播放器按 `时长毫秒 / 数组长度` 计算间隔,Spine 导出时再换算秒数并推导 FPS。音频 / 视频 `durationSeconds` 不映射到这两个字段。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`。
|
||||
|
||||
## 多模态角色动作参考必须绑定上传上下文并由后端读取权威规格
|
||||
|
||||
- 现象:普通视频与 Spine 序列帧面板共用参考上传控件时,用户在异步探测或上传期间切换面板,完成结果可能串入新面板;`same` 比例若直接采用客户端宽高,还会把省略或伪造尺寸的视频错误归一化为 `1:1`。
|
||||
- 处理:图片、视频、音频参考上传必须以 generation dialog 身份和分媒体容量建立 reservation,在探测、OSS 上传、项目资源 / 素材持久化和回填各边界复验;序列帧单参考限制按 dialog 语义判断,不依赖 `activeTool`。后端在扣费和 Provider 调用前按 owner 解析稳定对象,视频用带总超时的同一 HTTP client 做 HEAD 与 50MB 有界流式 GET,再由 FFprobe 读取 2–15 秒时长、显示方向宽高;图片按 EXIF 方向后的实际解码尺寸处理。`same` 只使用这些权威尺寸重新归一化。
|
||||
- 拆帧:省略素材目录时先转换为 owner 的正式默认文件夹 ID 并做 project/folder preflight;决定 resource、asset 与 cohort 的稳定 operation ID 属于资产领域规则,放在 `module-assets`,API 只编排调用。
|
||||
- 关联:`src/components/image-editor/useImageCanvasUploadWorkflow.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/module-assets/src/character_animation.rs`。
|
||||
|
||||
## 精选角色动作显示首帧还要检查前端 renderer 与逐帧授权
|
||||
|
||||
- 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## 部署身份与端口
|
||||
|
||||
`deploymentId` 由规范化分支名与分支名摘要确定,同一分支稳定得到同一 ID;commit 不进入 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。
|
||||
预览实例 ID(`deploymentId`)由规范化分支名与分支名摘要确定,只在控制服务内部和 Jenkins 参数中使用;commit 不进入该 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。页面发布记录的公开 ID 使用 Jenkins 构建编号:排队期间尚未分配编号,显示“待分配”,构建开始后立即显示真实编号。
|
||||
|
||||
Web 端口池固定为 `8400..8499`:
|
||||
|
||||
@@ -86,12 +86,18 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
|
||||
- `GET /api/preview-deployer/session`:查询当前会话状态。
|
||||
- `DELETE /api/preview-deployer/session`:退出。
|
||||
- `GET /api/preview-deployer/deployments`:列出由控制面触发和恢复的部署。
|
||||
- `GET /api/preview-deployer/refs/branches?q=`:按输入内容搜索固定源码仓库中的分支,最多返回 20 条 `{ name, commitHash }`。
|
||||
- `GET /api/preview-deployer/refs/commits?branch=&q=`:在已确认存在的目标分支历史中搜索 commit,最多返回 20 条 `{ commitHash, shortHash, subject }`。
|
||||
- `POST /api/preview-deployer/deployments`:提交 `{ branch, commitHash? }`。
|
||||
- `GET /api/preview-deployer/deployments/{id}`:刷新队列、构建与 artifact 状态。
|
||||
- `POST /api/preview-deployer/deployments/{id}/uninstall`:触发固定 Job 的卸载动作。
|
||||
|
||||
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。
|
||||
|
||||
发布记录卡片直接展示 Jenkins 构建编号、后端校验后的 `webPort` 和由控制服务转换的 Jenkins 局域网构建详情地址;内部 loopback Jenkins 地址不得返回浏览器。失败或取消且不存在可卸载实例的记录保留 7 天,成功卸载的内部记录保留 30 天,清理会在服务启动、读取列表和创建新构建时执行。仍可卸载的失败记录不会自动清理。`stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
|
||||
|
||||
分支名和 commit 输入框采用 300ms 防抖搜索,并在输入框下方显示服务端结果;分支变化时清空已输入的 commit,避免把旧分支 commit 带入新请求。搜索结果只负责辅助填写,不作为构建授权或存在性真相。`POST /deployments` 在写入排队状态和触发 Jenkins 前必须重新查询固定远端:分支不存在时拒绝;填写 commit 时必须确认它可解析为 commit object 且是目标分支 HEAD 的祖先。远端查询失败时失败关闭,不得触发 Jenkins。Jenkins checkout 继续执行相同的最终归属校验,以覆盖预检到排队之间的分支变化。
|
||||
|
||||
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。
|
||||
|
||||
控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。
|
||||
@@ -101,6 +107,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
|
||||
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
|
||||
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。
|
||||
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
|
||||
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
|
||||
- Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
|
||||
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
|
||||
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
# 【技术方案】角色设定 → 多方向角色图 → 拆分 → 动画生成
|
||||
|
||||
- 日期:2026-08-12
|
||||
- 状态:设计稿 v3(简化:唯一新增能力是"从角色设定生成多向角色图",拆分与动画全走现有能力)
|
||||
- 实现状态:**核心已实现**(多向图生成:后端 `character-sheet` kind + 前端角色面板开关,2026-08-12)
|
||||
- 方向固定为**八方向**(上、下、左、右 + 四角,围绕一圈排布,3×3 九宫格中心留空);提示词按八向围绕一圈约束排布,降低拆分粘连概率。
|
||||
- 拆分:复用既有「拆分图集」的「连通块定位 + 外接矩形截图」算法(`slice_generated_icon_spritesheet_all_by_connected_components`),不新增像素级连通块掩码;切出的单视角切片按 `icon` 落库(`split_editor_icon_spritesheet`),不改 `character`。
|
||||
- 关联:`【技术方案】ArtAgent多模态Spine序列帧动画生成-2026-08-10.md`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
- Spine 序列帧动画(`character-animation`)是"图生视频 → 抽帧",图生视频以首帧为准;首帧里角色越多,输出多角色概率越大。
|
||||
- 现状角色形象以多视角规范图为参考,可能产出"一个角色多个视角"的多角色图,直接当动画首帧会多角色。
|
||||
- 结论:需要从角色设定**能生成一张"多方向角色图"**(同一角色、多方向、彼此分离),拆成单张后分别做动画。
|
||||
|
||||
## 2. 范围
|
||||
|
||||
**唯一新增能力**:从角色设定(角色规范图 / 已有单视角角色图)生成一张**多方向角色图**(八方向:上、下、左、右 + 四角,围绕一圈排布)。
|
||||
|
||||
**其余全部复用现有能力,不做额外保障:**
|
||||
- 图集类型:复用现有 `icon-spritesheet`(「图集」),不新增资产类型。
|
||||
- 拆分:复用现有「拆分图集」(`split_editor_icon_spritesheet`,连通块定位 + 外接矩形截图),**手动触发**,不自动拆分。
|
||||
- 方向/数量:**不保证**。生成错了用户重新抽卡;方向标签用户自己看。
|
||||
- 动画:对拆分出的单视角图,按现有动画面板逐个生成。
|
||||
|
||||
## 3. 流程
|
||||
|
||||
```
|
||||
角色设定(规范图 / 已有角色图)
|
||||
│ 参考
|
||||
▼
|
||||
【新增】生成多方向角色图 → 产出 icon-spritesheet 图集
|
||||
│
|
||||
▼
|
||||
【现有】用户手动「拆分图集」→ N 张单视角图(按 icon 落库)
|
||||
│
|
||||
▼
|
||||
【现有】选单视角图 → 图生视频 → 抽帧 → Spine序列帧
|
||||
```
|
||||
|
||||
## 4. 新增能力:生成多方向角色图
|
||||
|
||||
- 入口:在「生成角色形象」面板底部,作为「多方向图」可勾选项,与「像素艺术」并列;勾选后本次生成走 `character-sheet` 图集模式。
|
||||
- 链路:复用图集生成链路(`generate_editor_icon_spritesheet_for_owner`),模型 `nanobanana2`。
|
||||
- 输出:一张 `icon-spritesheet` 图集,八个方向(正面、右前、右侧、右后、背面、左后、左侧、左前),按 3×3 九宫格围绕一圈排布、中心留空,同一角色、方向之间纯色底充分间隔、彼此分离。
|
||||
- 提示词是质量关键(草案):
|
||||
|
||||
```
|
||||
根据角色设定生成游戏角色的八方向视图图集。
|
||||
画面中只有同一个角色,共八个方向:正面、右前、右侧、右后、背面、左后、左侧、左前,每个方向一个完整角色。
|
||||
八个方向必须是同一个角色,脸型、发型、服装、配色、武器、头身比、体型全程一致,禁止漂移、换装、变形。
|
||||
八个方向按三乘三九宫格围绕一圈排布:上、下、左、右以及四个角落各放一个方向,九宫格中心留空,不放置角色。
|
||||
每个方向角色完整居中,不裁切头顶、手脚或武器;各方向底部基线对齐,画面占比一致。
|
||||
背景为单一纯色(绿幕 #00FF00),平整无纹理、无渐变、无阴影、无地面、无道具;角色之间由纯色背景充分间隔,互不接触、互不重叠,便于后续拆分。
|
||||
禁止额外角色、分身、宠物、文字、水印、边框、编号、网格线。
|
||||
角色设定:{}
|
||||
```
|
||||
|
||||
- 参考归属校验:复用 `resolve_editor_reference_object_key_for_owner` / `ensure_editor_reference_object_key_owned`,禁止跨账号 objectKey。
|
||||
|
||||
## 5. 使用流程(全部现有)
|
||||
|
||||
1. 生成多方向角色图 → 得到 `icon-spritesheet` 图集图层。
|
||||
2. 选中图集 → 点「拆分图集」(现有按钮)→ 得到若干单视角图。
|
||||
3. 对每个想用的方向,选对应单视角图 → 动画面板生成(默认右视图起步)。
|
||||
|
||||
## 6. 边界
|
||||
|
||||
- 生成质量靠提示词;图不对 → 用户重新抽卡,**不做数量/方向校验**。
|
||||
- 外接矩形若粘连(方向贴太近)→ 拆分数量不对 → 重新抽卡或人工处理,不自动兜底;提示词以「围绕一圈 + 纯色背景充分间隔」降低粘连概率。
|
||||
- 成本:多向图一次图像生成;拆分 0 provider 成本;每个方向动画各一次(现有计价)。
|
||||
- 旧链路(直接拿单视角角色图做动画)不变。
|
||||
|
||||
## 7. 验收
|
||||
|
||||
1. 从角色设定能生成一张多方向角色图,各方向为同一角色。
|
||||
2. 该图可作为图集手动拆分,拆出的单视角图能进动画面板生成单角色序列帧。
|
||||
3. 旧链路回归不破。
|
||||
|
||||
## 8. 决策点(留给产品)
|
||||
|
||||
- 方向数已定**八方向**(上、下、左、右 + 四角,围绕一圈;不再提供四视角选项)。
|
||||
- 入口已定:角色面板底部「多方向图」可勾选项,与「像素艺术」并列。
|
||||
- 多向图背景色(当前固定绿幕 #00FF00)。
|
||||
|
||||
## 9. 改动文件
|
||||
|
||||
- 前端:`ImageCanvasGenerationModel.ts`(多向图提示词/常量)、`ImageCanvasGenerationDialogModel.ts`、`ImageCanvasGenerationSubmissionModel.ts`、`ImageCanvasCharacterGenerationComposerView.tsx`(底部「多方向图」勾选项)、`ImageCanvasGenerationImageOptionsView.tsx`(`extraToggles` 插槽)。
|
||||
- 后端:`editor_project.rs`(`build_editor_character_multi_view_sheet_prompt` 八向围绕一圈提示词)。
|
||||
- 无需动拆分逻辑、动画逻辑、存储层;切片按 `icon` 落库,不加 `character` 分支。
|
||||
@@ -345,7 +345,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;下述逐玩法生成和写回描述均为退役前历史。历史 pending / running 行继续保留原状态,不得领取、失败收口或改写 payload。
|
||||
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_character_animation_video_conversion`、`editor_character_animation_background_removal`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 2026-08-06 收口覆盖:上一条用途描述中“当前先 CAS、单事务仍是目标”的旧句已作废。现役编辑器生成不再组合调用 object confirm、resource create、asset create、canvas save 和 job complete。`api-server` 只准备稳定候选,再经 `spacetime-client` 调用 `persist_editor_generation_result_and_return`;procedure 在同一 `try_with_tx` 内写入可选 `asset_object`、全部 `editor_project_resource`、`editor_asset`、可选 `asset_entity_binding`、可选 canvas V2 CAS、queue job 终态和 `editor_generation_operation` receipt。结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 仍经既有 V2 布局验证分流,前端不直接发明正式完成态。queue 首次提交在同一快照验证 owner、job kind、request fingerprint 和有效 `job_id + worker_id + lease_token`;统一 procedure 已完成 job 后 worker 不得再单独 complete。
|
||||
- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。
|
||||
- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。
|
||||
|
||||
@@ -68,7 +68,7 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg
|
||||
|
||||
开发态 `npm run dev` 与 `npm run dev:api-server` 都会注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号。完整 `npm run dev` 会强制父 API 使用 `GENARRATIVE_PROCESS_ROLE=all`,忽略外层显式角色,确保本地 `api-server` 同时监听 HTTP 并消费外部生成队列;只有单模块 `npm run dev:api-server` 会保留显式 `GENARRATIVE_PROCESS_ROLE`,未设置时默认为 `all`。`all` 不内嵌 BgFilter worker;启动器总是先启动并验活独立 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程,再启动父 API,并向两者注入同一个内部 base URL / Token。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。
|
||||
|
||||
本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。旧玩法历史任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。
|
||||
本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_character_animation_video_conversion`、`editor_character_animation_background_removal`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。旧玩法历史任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。
|
||||
|
||||
HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer 调用池大小;池连接不订阅 read model。HTTP 角色会额外创建 1 条共享缓存读连接,当前只保留可选的 `user_account` 读取;配置为 `8` 时基础连接拓扑是 8 条调用连接加 1 条缓存读连接。`/readyz` 同时检查调用池与缓存读连接,缓存连接未准备好时不能放量。
|
||||
|
||||
@@ -675,7 +675,7 @@ npm run container:down
|
||||
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
||||
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
||||
|
||||
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。
|
||||
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。
|
||||
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
|
||||
|
||||
独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。
|
||||
|
||||
@@ -100,6 +100,82 @@ export function resolveSelectedToolbarStyle({
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCharacterAnimationBackgroundRemovalPanelStyle({
|
||||
sourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!sourceLayer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelWidth = 288;
|
||||
const panelHeight = 440;
|
||||
const gap = 12;
|
||||
const edge = 12;
|
||||
const sourceLeft = viewport.x + sourceLayer.x * viewport.scale;
|
||||
const sourceTop = viewport.y + sourceLayer.y * viewport.scale;
|
||||
const sourceWidth = sourceLayer.width * viewport.scale;
|
||||
const sourceHeight = sourceLayer.height * viewport.scale;
|
||||
const sourceRight = sourceLeft + sourceWidth;
|
||||
const sourceBottom = sourceTop + sourceHeight;
|
||||
const clampPanelLeft = (left: number) =>
|
||||
Math.min(Math.max(edge, left), Math.max(edge, canvasSize.width - panelWidth - edge));
|
||||
const clampPanelTop = (top: number) =>
|
||||
Math.min(Math.max(edge, top), Math.max(edge, canvasSize.height - panelHeight - edge));
|
||||
|
||||
const rightLeft = sourceRight + gap;
|
||||
if (rightLeft + panelWidth <= canvasSize.width - edge) {
|
||||
return { left: rightLeft, top: clampPanelTop(sourceTop) };
|
||||
}
|
||||
|
||||
const leftLeft = sourceLeft - gap - panelWidth;
|
||||
if (leftLeft >= edge) {
|
||||
return { left: leftLeft, top: clampPanelTop(sourceTop) };
|
||||
}
|
||||
|
||||
const bottomTop = sourceBottom + gap;
|
||||
if (bottomTop + panelHeight <= canvasSize.height - edge) {
|
||||
return {
|
||||
left: clampPanelLeft(sourceLeft + sourceWidth / 2 - panelWidth / 2),
|
||||
top: bottomTop,
|
||||
};
|
||||
}
|
||||
|
||||
const topTop = sourceTop - gap - panelHeight;
|
||||
if (topTop >= edge) {
|
||||
return {
|
||||
left: clampPanelLeft(sourceLeft + sourceWidth / 2 - panelWidth / 2),
|
||||
top: topTop,
|
||||
};
|
||||
}
|
||||
|
||||
// 极端缩放下四侧都放不下完整面板时,仍把面板放在可用空间最大的一侧,
|
||||
// 保证它不覆盖源画面;viewport 会裁掉越界部分,用户可先缩小画布继续操作。
|
||||
const availableSides = [
|
||||
{ space: canvasSize.width - sourceRight, left: rightLeft, top: clampPanelTop(sourceTop) },
|
||||
{ space: sourceLeft, left: leftLeft, top: clampPanelTop(sourceTop) },
|
||||
{
|
||||
space: canvasSize.height - sourceBottom,
|
||||
left: clampPanelLeft(sourceLeft + sourceWidth / 2 - panelWidth / 2),
|
||||
top: bottomTop,
|
||||
},
|
||||
{
|
||||
space: sourceTop,
|
||||
left: clampPanelLeft(sourceLeft + sourceWidth / 2 - panelWidth / 2),
|
||||
top: topTop,
|
||||
},
|
||||
];
|
||||
const bestSide = availableSides.reduce((best, candidate) =>
|
||||
candidate.space > best.space ? candidate : best,
|
||||
);
|
||||
return { left: bestSide.left, top: bestSide.top };
|
||||
}
|
||||
|
||||
export function resolveQuickEditFocusViewport({
|
||||
sourceLayer,
|
||||
canvasSize,
|
||||
|
||||
@@ -24,6 +24,7 @@ export type CanvasGenerationAction =
|
||||
| 'scene.generate'
|
||||
| 'spec.generate'
|
||||
| 'character.generate'
|
||||
| 'character-sheet.generate'
|
||||
| 'icon.generate'
|
||||
| 'ui-design.generate'
|
||||
| 'publication.generate'
|
||||
@@ -31,6 +32,8 @@ export type CanvasGenerationAction =
|
||||
| 'audio.sound-effect.generate'
|
||||
| 'audio.background-music.generate'
|
||||
| 'character-animation.generate'
|
||||
| 'character-animation.convert'
|
||||
| 'character-animation.remove-background'
|
||||
| 'image.edit'
|
||||
| 'ui-design.extract-assets'
|
||||
| 'image.perfect-pixel'
|
||||
@@ -137,6 +140,7 @@ export type CanvasTool =
|
||||
| 'upload'
|
||||
| 'generate'
|
||||
| 'video'
|
||||
| 'character-animation'
|
||||
| 'music'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
@@ -203,7 +207,7 @@ export type CropExpandPanelState = {
|
||||
};
|
||||
|
||||
export type CharacterAnimationPanelState = {
|
||||
sourceLayerId: string;
|
||||
sourceLayerId?: string;
|
||||
promptText: string;
|
||||
status: 'idle' | 'generating' | 'completed' | 'failed';
|
||||
errorMessage?: string;
|
||||
|
||||
@@ -326,7 +326,8 @@ export function useImageCanvasStageInteractions({
|
||||
(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'scene' ||
|
||||
generateDialog?.mode === 'video' ||
|
||||
generateDialog?.mode === 'spec')
|
||||
generateDialog?.mode === 'spec' ||
|
||||
generateDialog?.mode === 'character-animation')
|
||||
) {
|
||||
focusCanvasInteractionTarget(event.currentTarget);
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type EditorCharacterAnimationFrameSplitInput = {
|
||||
projectId: string;
|
||||
sourceLayerId: string;
|
||||
sourceResourceId: string;
|
||||
assetFolderId?: string | null;
|
||||
assetLabel?: string | null;
|
||||
};
|
||||
|
||||
export type EditorCharacterAnimationFrameSplitResult<TAsset> = {
|
||||
frameCount: number;
|
||||
assets: TAsset[];
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export type * from './contracts/creationAudio';
|
||||
export type * from './contracts/creativeAgent';
|
||||
export type * from './contracts/customWorldAgent';
|
||||
export type * from './contracts/editorAudio';
|
||||
export type * from './contracts/editorCharacterAnimation';
|
||||
export * from './contracts/editorScene';
|
||||
export * from './contracts/edutainmentBabyDrawing';
|
||||
export * from './contracts/edutainmentBabyObject';
|
||||
|
||||
@@ -21,6 +21,10 @@ const systemd = readFileSync(
|
||||
'deploy/systemd/genarrative-preview-deployer.service',
|
||||
'utf8',
|
||||
);
|
||||
const environmentExample = readFileSync(
|
||||
'deploy/env/preview-deployer.env.example',
|
||||
'utf8',
|
||||
);
|
||||
const server = readFileSync(
|
||||
'server-rs/crates/preview-deployer-server/src/lib.rs',
|
||||
'utf8',
|
||||
@@ -162,6 +166,21 @@ assertIncludes(
|
||||
'.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))',
|
||||
'控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。',
|
||||
);
|
||||
assertIncludes(
|
||||
server,
|
||||
'FAILED_RECORD_TTL_SECS',
|
||||
'控制服务必须清理过期且不可卸载的失败/取消记录。',
|
||||
);
|
||||
assertIncludes(
|
||||
server,
|
||||
'STOPPED_RECORD_TTL_SECS',
|
||||
'控制服务必须为已卸载记录配置有限审计保留期。',
|
||||
);
|
||||
assertIncludes(
|
||||
environmentExample,
|
||||
'GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/',
|
||||
'构建详情必须使用局域网 Jenkins 地址而非 loopback。',
|
||||
);
|
||||
assertIncludes(
|
||||
jobConfig,
|
||||
'<scriptPath>jenkins/Jenkinsfile.preview-deployer</scriptPath>',
|
||||
|
||||
Generated
+1
@@ -2940,6 +2940,7 @@ dependencies = [
|
||||
"platform-oss",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
@@ -1855,6 +1855,54 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_character_animation_video_conversion_requires_bearer_auth() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/character-animations/video-conversions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"sourceVideoSrc": "users/owner/source.mp4",
|
||||
"resolution": "480p"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_character_animation_frame_split_requires_bearer_auth() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let request_body = serde_json::json!({
|
||||
"projectId": "project-1",
|
||||
"sourceLayerId": "layer-character-animation",
|
||||
"sourceResourceId": "resource-character-animation"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/character-animations/frame-splits")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_ui_design_asset_extraction_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
|
||||
@@ -930,7 +930,7 @@ mod tests {
|
||||
for (source, expected_calls) in [
|
||||
(include_str!("editor_project.rs"), 4),
|
||||
(include_str!("editor_project_icon.rs"), 2),
|
||||
(include_str!("character_animation_assets.rs"), 2),
|
||||
(include_str!("character_animation_assets.rs"), 4),
|
||||
(
|
||||
include_str!("vector_engine_audio_generation/generation.rs"),
|
||||
2,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user