diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d940b0..643a02b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,12 +195,22 @@ jobs: - name: Exercise actual demo in Chromium working-directory: apps/web run: npm run test:demo + - name: Enable the optional authoring console in the disposable demo + run: | + docker compose -f docker-compose.yml -f docker-compose.demo-ci.yml -f docker-compose.authoring.yml build web + docker compose -f docker-compose.yml -f docker-compose.demo-ci.yml -f docker-compose.authoring.yml up -d --no-deps --wait web + - name: Create, preview and publish a captured range through the real console + working-directory: apps/web + run: npm run test:demo:authoring - name: Upload browser evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: demo-browser-${{ github.sha }} - path: apps/web/test-results/demo-journey + path: | + apps/web/test-results/demo-journey + apps/web/test-results/viewer-camera-continuity + apps/web/test-results/demo-authoring if-no-files-found: warn - name: Collect demo runtime diagnostics if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index ace6056..75ca8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased v0.2.0 candidate + +- Added an optional visual authoring console for mapping one video range to a captured model point, previewing privately and publishing the annotation individually. +- Clarified the generated demo's three chapters and added an explicit guarded refresh with rollback for existing synthetic installations. +- Preserved a manually chosen camera during direct pin playback and retained selection at a guided range's exclusive end. +- Corrected blank point validation, dark-mode branding, the retained muted-light palette and the generated Top edge placement. +- Refresh clean transcript drafts from the latest server text, preserve unsaved edits during tab changes, and require explicit reconciliation before saving a conflicting transcript. +- Added the 3D Humanities Lab's complete-viewport authoring tutorial, poster, timed description and setup guide. +- Patched brace-expansion and PyJWT dependency pins with matching license notices, and expanded browser and real-API demo checks. +- Aligned the API, web package and software citation version metadata to 0.2.0, and clarified setup and security documentation for the source and earlier releases. + +## v0.1.1, September 29, 2026 + +- Corrected public static-asset permissions, added visible evidence loading progress and the muted-light theme, and improved public object ordering, mobile cards and pagination. +- Added the protected contribution and owner-controlled release process, including the four required CI checks. +- Replaced browser and citation fixtures with generated sample content and added a public-fixture boundary guard. + +The [v0.1.1 release](https://github.com/3dhlab/loci/releases/tag/v0.1.1) combines Unlock Digital's contributions and its assessed adaptations of Lab proposals. All four checks passed on the final main commit and tag. Hosted operations, larger-collection performance and older source archives remain separate assessments. + ## v0.1.0, September 27, 2026 - Added a local example linking points on a 3D model to video, transcripts, citations and share links. diff --git a/CITATION.cff b/CITATION.cff index 19c74fa..879714c 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -3,7 +3,7 @@ message: "Please cite Loci when using the software in research." title: "Loci: Spatially linked video evidence" type: software license: Apache-2.0 -version: "0.1.0" +version: "0.2.0" authors: - family-names: "Stevens" given-names: "Craig" diff --git a/PYTHON_THIRD_PARTY_NOTICES.md b/PYTHON_THIRD_PARTY_NOTICES.md index 387efc8..d70ba3e 100644 --- a/PYTHON_THIRD_PARTY_NOTICES.md +++ b/PYTHON_THIRD_PARTY_NOTICES.md @@ -6639,7 +6639,7 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## PyJWT 2.13.0 | pyjwt-2.13.0.dist-info/licenses/AUTHORS.rst +## PyJWT 2.15.0 | pyjwt-2.15.0.dist-info/licenses/AUTHORS.rst ```text Authors @@ -6651,7 +6651,7 @@ Originally written and maintained by `Jeff Lindsay A full list of contributors can be found on GitHub’s `overview `_. ``` -## PyJWT 2.13.0 | pyjwt-2.13.0.dist-info/licenses/LICENSE +## PyJWT 2.15.0 | pyjwt-2.15.0.dist-info/licenses/LICENSE ```text The MIT License (MIT) diff --git a/README.md b/README.md index c3d5d8a..e920408 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Loci connects locations on a 3D model to video evidence, timed transcripts, citations, and shareable moments. Researchers can select a point on an object, watch the related video section, read its transcript and share the evidence with others. -The public [v0.1.0 release](https://github.com/3dhlab/loci/releases/tag/v0.1.0) includes a local demonstration with one authoring account. The sample model, video, transcript, and annotations are created specifically for the demonstration. Loci's original software, documentation and generated samples are licensed under [Apache 2.0](LICENSE); [licensing details](LICENSE_STATUS.md) explain the scope and third-party terms. +The source includes a local demonstration with one authoring account; [published releases](https://github.com/3dhlab/loci/releases) identify the available packages. The sample model, video, transcript, and annotations are created specifically for the demonstration. Loci's original software, documentation and generated samples are licensed under [Apache 2.0](LICENSE); [licensing details](LICENSE_STATUS.md) explain the scope and third-party terms. ## Hosted research instance @@ -30,9 +30,28 @@ The demo login is `demo@example.org`. Read its generated `DEMO_PASSWORD` from yo `docker compose down` stops services and preserves data. `docker compose down --volumes` deletes the demo database and media for that Compose project. Keep `.env` for the lifetime of its database volume. +## Local demo walkthrough + +[![Watch the Loci local demo and visual authoring walkthrough](docs/media/demo-authoring/poster.jpg)](docs/media/demo-authoring/loci-demo-authoring.mp4) + +[Watch or download the video](docs/media/demo-authoring/loci-demo-authoring.mp4) · [Timed text description](docs/media/demo-authoring/transcript.md) · [Follow the authoring guide](docs/demo-authoring.md) + +This silent 41-second walkthrough shows the generated cube in the public reader, +then selects a video range, places and refines a pin, previews it privately, +publishes that annotation, and verifies the result in the reader. The demo code +improvements and this video are a **3D Humanities Lab** contribution. The +[video provenance and license](docs/media/demo-authoring/README.md) describe its +generated content. + +Build a source checkout or release archive containing `docker-compose.authoring.yml` +to follow the optional visual authoring walkthrough. The v0.1.1 release predates +that override and the updated chapter media. + ## Documentation - [Synthetic demo and content workflow](examples/README.md) +- [Optional visual authoring console and single-range walkthrough](docs/demo-authoring.md) +- [Spatial selection, camera continuity and playback verification](docs/viewer-camera-continuity.md) - [Architecture and trust boundaries](docs/architecture.md) - [Model, annotation, transcript and media contracts](docs/data-contract.md) - [Development and contribution checks](CONTRIBUTING.md) diff --git a/SECURITY.md b/SECURITY.md index 1e2b3a3..3a20057 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,4 +4,8 @@ Send vulnerability reports to Craig Stevens at craig.stevens@austin.utexas.edu. Use [GitHub's private vulnerability reporting](https://github.com/3dhlab/loci/security/advisories/new) or the email address above for security reports. Use [GitHub Issues](https://github.com/3dhlab/loci/issues) for ordinary bugs and support questions. -Security testing for v0.1.0 covers the documented local demonstration and its GitHub Actions checks. A hosted installation needs a review of its authentication, network access, secrets and operating procedures. Dependency audits describe the versions and advisories checked at a particular time; repeat them as the software changes. Maintainers will document security fixes and supported versions in future releases. +Security verification covers the documented local demonstration and its required GitHub Actions checks, including authentication, public/private projection and disposable database/media services. A hosted installation needs a separate review of its authentication, network access, secrets and operating procedures. + +This source pins brace-expansion 5.0.12 and PyJWT 2.15.0. Earlier v0.1.0 and v0.1.1 archives retain their previous dependency versions; use the [release notes](https://github.com/3dhlab/loci/releases) and the manifests in your installed checkout to identify the applicable fixes. Updating the source alone does not rebuild an existing installation. Rebuild its API and web images using the documented setup, preserve its database/media volumes, and follow the [explicit generated-demo refresh](examples/README.md#refresh-the-generated-sample-in-an-existing-installation) only for an eligible synthetic installation. + +Dependency audits describe the versions and advisories checked at a particular time; repeat them as the software changes. These checks do not establish whole-system security or production-hosting readiness. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ea33c80..5b692ab 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4303,7 +4303,7 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## brace-expansion 5.0.9 | LICENSE +## brace-expansion 5.0.12 | LICENSE ```text MIT License @@ -4331,7 +4331,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## brace-expansion 5.0.9 | license +## brace-expansion 5.0.12 | license ```text MIT License diff --git a/apps/api/app/api/v1/endpoints/videos.py b/apps/api/app/api/v1/endpoints/videos.py index 58e8916..a4df2f3 100644 --- a/apps/api/app/api/v1/endpoints/videos.py +++ b/apps/api/app/api/v1/endpoints/videos.py @@ -16,7 +16,7 @@ from app.models.entities import Object, Project, User, Video, VideoStatus from app.schemas.video import MediaFileResponse, VideoCreate, VideoCreateResponse, VideoResponse, VideoUpdate from app.services.audit_events import append_metadata_change_event, append_publication_state_event, build_user_actor_payload -from app.services.media_transcode import transcode_output_path +from app.services.media_transcode import transcode_output_path, video_playback_path from app.services.job_queue import enqueue_transcode, enqueue_transcription from app.services.upload_validation import UploadTooLargeError, UploadValidationError, persist_bounded_upload @@ -208,8 +208,8 @@ def stream_video(video_id: UUID, db: Session = Depends(get_db), current_user: Us detail="Video is still processing. Playback is enabled when status is READY.", ) - playback_path = transcode_output_path(str(video.id)) - if not playback_path.exists() or not playback_path.is_file(): + playback_path = video_playback_path(video) + if playback_path is None or not playback_path.exists() or not playback_path.is_file(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Normalized playback asset is unavailable for this video.", diff --git a/apps/api/app/main.py b/apps/api/app/main.py index ae5b02e..e65ac09 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -47,7 +47,7 @@ async def lifespan(_: FastAPI): if warmup_task is not None: warmup_task.cancel() -app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan) +app = FastAPI(title=settings.app_name, version="0.2.0", lifespan=lifespan) app.add_middleware(RateLimitMiddleware) app.add_middleware( CORSMiddleware, diff --git a/apps/api/app/scripts/demo_storyboard.py b/apps/api/app/scripts/demo_storyboard.py new file mode 100644 index 0000000..d4255cf --- /dev/null +++ b/apps/api/app/scripts/demo_storyboard.py @@ -0,0 +1,171 @@ +"""Portable raster storyboard for the original, silent synthetic demonstration. + +The small bitmap alphabet and geometry are authored here. Rendering requires +Python's standard library; FFmpeg receives RGB frames without font dependencies. +""" +from __future__ import annotations + +import math + +WIDTH, HEIGHT, FPS = 960, 540, 24 +VERSION = 'loci-synthetic-chapters-v2' +CHAPTERS = ( + {'title': 'Front face', 'shape': 'Square', 'color': '208cb8', + 'start_ms': 0, 'end_ms': 4000, + 'text': 'Section 1: Front face. A square marks the front face of the cube.'}, + {'title': 'Top edge', 'shape': 'Triangle', 'color': 'e6a03c', + 'start_ms': 4000, 'end_ms': 8000, + 'text': 'Section 2: Top edge. A triangle marks the top edge of the cube.'}, + {'title': 'Compare view', 'shape': 'Circle', 'color': '7056a0', + 'start_ms': 8000, 'end_ms': 12000, + 'text': 'Section 3: Compare view. A circle marks the side face of the cube.'}, +) +BACKGROUND = (16, 27, 44) +WHITE = (248, 250, 252) +MUTED = (191, 207, 225) + +# Seven rows of five pixels per glyph. Block strokes remain clear at small sizes. +_GLYPHS = { + 'A':'01110/10001/10001/11111/10001/10001/10001', + 'B':'11110/10001/10001/11110/10001/10001/11110', + 'C':'01111/10000/10000/10000/10000/10000/01111', + 'D':'11110/10001/10001/10001/10001/10001/11110', + 'E':'11111/10000/10000/11110/10000/10000/11111', + 'F':'11111/10000/10000/11110/10000/10000/10000', + 'G':'01111/10000/10000/10111/10001/10001/01111', + 'H':'10001/10001/10001/11111/10001/10001/10001', + 'I':'11111/00100/00100/00100/00100/00100/11111', + 'J':'00111/00010/00010/00010/10010/10010/01100', + 'K':'10001/10010/10100/11000/10100/10010/10001', + 'L':'10000/10000/10000/10000/10000/10000/11111', + 'M':'10001/11011/10101/10101/10001/10001/10001', + 'N':'10001/11001/10101/10011/10001/10001/10001', + 'O':'01110/10001/10001/10001/10001/10001/01110', + 'P':'11110/10001/10001/11110/10000/10000/10000', + 'Q':'01110/10001/10001/10001/10101/10010/01101', + 'R':'11110/10001/10001/11110/10100/10010/10001', + 'S':'01111/10000/10000/01110/00001/00001/11110', + 'T':'11111/00100/00100/00100/00100/00100/00100', + 'U':'10001/10001/10001/10001/10001/10001/01110', + 'V':'10001/10001/10001/10001/10001/01010/00100', + 'W':'10001/10001/10001/10101/10101/10101/01010', + 'X':'10001/10001/01010/00100/01010/10001/10001', + 'Y':'10001/10001/01010/00100/00100/00100/00100', + 'Z':'11111/00001/00010/00100/01000/10000/11111', + '0':'01110/10001/10011/10101/11001/10001/01110', + '1':'00100/01100/00100/00100/00100/00100/01110', + '2':'01110/10001/00001/00010/00100/01000/11111', + '3':'11110/00001/00001/01110/00001/00001/11110', + '4':'00010/00110/01010/10010/11111/00010/00010', + '5':'11111/10000/10000/11110/00001/00001/11110', + '6':'01110/10000/10000/11110/10001/10001/01110', + '7':'11111/00001/00010/00100/01000/01000/01000', + '8':'01110/10001/10001/01110/10001/10001/01110', + '9':'01110/10001/10001/01111/00001/00001/01110', + ':':'00000/00100/00100/00000/00100/00100/00000', + '.':'00000/00000/00000/00000/00000/00100/00100', + '-':'00000/00000/00000/11111/00000/00000/00000', + '/':'00001/00001/00010/00100/01000/10000/10000', + ' ':'00000/00000/00000/00000/00000/00000/00000', +} + + +class Canvas: + def __init__(self, pixels: bytes | None = None): + self.pixels = bytearray(pixels) if pixels else bytearray(bytes(BACKGROUND) * WIDTH * HEIGHT) + + def rect(self, x, y, width, height, color): + x, y, width, height = map(int, (x, y, width, height)) + left, right = max(0, x), min(WIDTH, x + width) + if right <= left: + return + row = bytes(color) * (right - left) + for py in range(max(0, y), min(HEIGHT, y + height)): + offset = (py * WIDTH + left) * 3 + self.pixels[offset:offset + len(row)] = row + + def dot(self, x, y, radius, color): + for dy in range(-radius, radius + 1): + half = int(math.sqrt(max(0, radius * radius - dy * dy))) + self.rect(round(x) - half, round(y) + dy, half * 2 + 1, 1, color) + + def line(self, start, end, color, thickness=4): + dx, dy = end[0] - start[0], end[1] - start[1] + steps = max(abs(dx), abs(dy), 1) + for i in range(int(steps) + 1): + self.dot(start[0] + dx * i / steps, start[1] + dy * i / steps, thickness // 2, color) + + def text(self, x, y, value, scale=3, color=WHITE): + for character in value.upper(): + for row, bits in enumerate(_GLYPHS[character].split('/')): + for column, bit in enumerate(bits): + if bit == '1': + self.rect(x + column * scale, y + row * scale, scale, scale, color) + x += scale * 6 + + def symbol(self, center, shape, radius=29): + x, y = center + if shape == 'Square': + points = [(x-radius,y-radius),(x+radius,y-radius),(x+radius,y+radius),(x-radius,y+radius)] + elif shape == 'Triangle': + points = [(x,y-radius),(x+radius,y+radius),(x-radius,y+radius)] + else: + points = [(round(x+radius*math.cos(i*math.pi/24)),round(y+radius*math.sin(i*math.pi/24))) for i in range(48)] + for start, end in zip(points, points[1:] + points[:1]): + self.line(start, end, WHITE, 6) + + +def chapter_base(index: int) -> bytes: + chapter = CHAPTERS[index] + accent = tuple(bytes.fromhex(chapter['color'])) + c = Canvas() + c.rect(0, 0, WIDTH, 12, accent) + c.text(48, 40, 'ANNOTATION EVIDENCE', 3, MUTED) + c.text(48, 108, f'0{index + 1}', 9) + c.text(48, 194, chapter['title'], 6) + c.symbol((78, 290), chapter['shape']) + c.text(132, 278, chapter['shape'], 4) + c.text(48, 356, f"RANGE 00:{index * 4:02d} - 00:{(index + 1) * 4:02d}", 3, MUTED) + # Fixed isometric cube: the marker traces the feature linked to the annotation. + c.rect(608, 98, 304, 288, accent) + front = [(646,216),(800,216),(800,352),(646,352)] + back = [(730,154),(884,154),(884,290),(730,290)] + for polygon in (front, back): + for start, end in zip(polygon, polygon[1:] + polygon[:1]): + c.line(start, end, BACKGROUND, 4) + for start, end in zip(front, back): + c.line(start, end, BACKGROUND, 4) + paths = [front + [front[0]], [front[0], front[1]], [front[1],back[1],back[2],front[2],front[1]]] + for start, end in zip(paths[index], paths[index][1:]): + c.line(start, end, BACKGROUND, 13) + c.line(start, end, WHITE, 7) + c.text(48, 434, 'VIDEO TIME', 3, MUTED) + c.text(738, 434, '/ 00:12.0', 3, MUTED) + c.rect(48, 492, 864, 12, (63, 80, 100)) + for x in (336, 624): + c.rect(x, 490, 3, 16, MUTED) + return bytes(c.pixels) + + +def render_frame(frame: int, bases: tuple[bytes, ...] | None = None) -> bytes: + if not 0 <= frame < FPS * 12: + raise ValueError('Frame must fall inside the 12-second demonstration.') + index = frame // (FPS * 4) + c = Canvas(bases[index] if bases else chapter_base(index)) + progress = (frame % (FPS * 4)) / (FPS * 4 - 1) + paths = [((646,216),(800,216),(800,352),(646,352),(646,216)), + ((646,216),(800,216)), + ((800,216),(884,154),(884,290),(800,352),(800,216))] + path = paths[index] + position = progress * (len(path) - 1) + step = min(int(position), len(path) - 2) + a, b = path[step], path[step + 1] + fraction = position - step + point = (a[0] + (b[0]-a[0])*fraction, a[1] + (b[1]-a[1])*fraction) + c.dot(*point, 12, BACKGROUND) + c.dot(*point, 7, WHITE) + # Absolute time remains truthful in derived clips and discontinuous sequences. + tenths = frame * 10 // FPS + c.text(486, 428, f'00:{tenths // 10:02d}.{tenths % 10}', 4) + c.rect(48, 492, round(864 * frame / (FPS * 12 - 1)), 12, WHITE) + return bytes(c.pixels) diff --git a/apps/api/app/scripts/generate_demo_assets.py b/apps/api/app/scripts/generate_demo_assets.py index 78bcd48..dfe87f6 100644 --- a/apps/api/app/scripts/generate_demo_assets.py +++ b/apps/api/app/scripts/generate_demo_assets.py @@ -1,10 +1,13 @@ """Generate original geometric sample media using the standard library and FFmpeg.""" from __future__ import annotations +import hashlib import json from pathlib import Path import struct import subprocess +from app.scripts.demo_storyboard import CHAPTERS, FPS, HEIGHT, VERSION, WIDTH, chapter_base, render_frame + def generate_assets(destination: Path) -> None: destination.mkdir(parents=True, exist_ok=True) @@ -25,16 +28,29 @@ def generate_assets(destination: Path) -> None: glb += struct.pack(' None: diff --git a/apps/api/app/scripts/replace_demo_media.py b/apps/api/app/scripts/replace_demo_media.py index b7c5bca..862b15e 100644 --- a/apps/api/app/scripts/replace_demo_media.py +++ b/apps/api/app/scripts/replace_demo_media.py @@ -5,6 +5,7 @@ import json from pathlib import Path import subprocess +import shutil from uuid import uuid4 from fastapi import UploadFile @@ -12,9 +13,133 @@ from app.api.v1.endpoints.object_models import upload_object_model from app.core.config import settings from app.db.session import SessionLocal -from app.models.entities import Object, ObjectModelAnnotation, AnnotationReviewStatus, Transcript, User, Video, Clip -from app.scripts.seed_demo import IDS +from app.models.entities import Object, ObjectModel, ObjectModelAnnotation, AnnotationReviewStatus, Transcript, User, Video, Clip, Segment +from app.scripts.seed_demo import ANNOTATION_POINTS, IDS, TEXT from app.scripts.generate_demo_assets import generate_clip +from app.scripts.demo_storyboard import CHAPTERS, FPS, HEIGHT, VERSION, WIDTH + +LEGACY_VIDEO_TITLE = 'Three color sections' +CURRENT_VIDEO_TITLE = 'Three cube views' +LEGACY_TRANSCRIPT_TITLE = 'Color demonstration transcript' +CURRENT_TRANSCRIPT_TITLE = 'Cube view demonstration transcript' + + +def validate_synthetic_assets(assets: Path) -> Path: + """Require the exact generated storyboard contract before preserving review.""" + video = assets / 'colors.mp4' + manifest = json.loads((assets / 'demo-manifest.json').read_text()) + expected = {'version': VERSION, 'width': WIDTH, 'height': HEIGHT, 'fps': FPS, + 'duration_ms': 12000, 'chapters': list(CHAPTERS)} + if any(manifest.get(key) != value for key, value in expected.items()): + raise SystemExit('Synthetic refresh requires the current generated chapter manifest.') + if not video.is_file() or video.stat().st_size > settings.media_upload_max_bytes: + raise SystemExit('Synthetic video must be a local file within the media upload limit.') + with video.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + if digest != manifest.get('video_sha256'): + raise SystemExit('Synthetic video checksum differs from its generated manifest.') + probe = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration', + '-show_entries','stream=width,height,r_frame_rate,codec_name', + '-of','json',str(video)],capture_output=True,text=True,check=True) + info = json.loads(probe.stdout) + streams = info['streams'] + if (len(streams) != 1 or streams[0] != {'codec_name': 'h264', 'width': WIDTH, + 'height': HEIGHT, 'r_frame_rate': f'{FPS}/1'} + or abs(float(info['format']['duration']) - 12) > .001): + raise SystemExit('Synthetic video must be the silent 12-second generated H.264 storyboard.') + return video + + +def refresh_synthetic(assets: Path) -> None: + """Refresh an untouched seed while retaining its annotation/review contract. + + Ordinary --video replacement continues to withdraw linked publication. + This opt-in path accepts the generated silent sample and original seed rows. + """ + if settings.semantic_env.lower() != 'development': + raise SystemExit('Synthetic refresh requires SEMANTIC_ENV=development.') + source = validate_synthetic_assets(assets) + cube_digest = hashlib.sha256((assets / 'cube.glb').read_bytes()).hexdigest() + if cube_digest != json.loads((assets / 'demo-manifest.json').read_text()).get('cube_sha256'): + raise SystemExit('Generated cube checksum differs from its manifest.') + legacy_text = ['The blue section introduces the cube and its front face.', + 'The amber section identifies the top edge of the cube.', + 'The purple section returns to the cube for comparison.'] + with SessionLocal() as db: + obj, user = db.get(Object, IDS['object']), db.get(User, IDS['user']) + model, video = db.get(ObjectModel, IDS['model']), db.get(Video, IDS['video']) + transcript = db.get(Transcript, IDS['transcript']) + annotations = list(db.execute(select(ObjectModelAnnotation).where(ObjectModelAnnotation.object_id == IDS['object'])).scalars()) + segments = list(db.execute(select(Segment).where(Segment.transcript_id == IDS['transcript']).order_by(Segment.position)).scalars()) + clips = list(db.execute(select(Clip).where(Clip.video_id == IDS['video']).order_by(Clip.start_ms)).scalars()) + if (obj is None or obj.website_object_id != 'demo-cube' or user is None + or user.email != 'demo@example.org' or model is None or model.sha256_checksum != cube_digest + or video is None or video.stable_video_id != 'demo-colors' or video.duration_ms != 12000 + or transcript is None or len(annotations) != 3 or len(segments) != 3 or len(clips) != 3): + raise SystemExit('Synthetic refresh requires the original seeded demo. Use ordinary replacement for personalized content.') + if [segment.text for segment in segments] not in (legacy_text, TEXT): + raise SystemExit('Transcript has been personalized; use ordinary replacement and review.') + for index, (segment, clip) in enumerate(zip(segments, clips), 1): + expected = ((index-1)*4000, index*4000) + if (segment.id != IDS[f'segment{index}'] or clip.id != IDS[f'clip{index}'] + or (segment.start_ms, segment.end_ms) != expected + or (clip.start_ms, clip.end_ms) != expected): + raise SystemExit('Seeded clip or transcript timing changed; use ordinary replacement and review.') + for index, title in enumerate(('Front face', 'Top edge', 'Compare two sections'), 1): + annotation = next((a for a in annotations if a.id == IDS[f'annotation{index}']), None) + expected_clips = [1, 3] if index == 3 else [index] + expected_ranges = [(0, 4000), (8000, 12000)] if index == 3 else [((index-1)*4000, index*4000)] + allowed_points = {ANNOTATION_POINTS[index-1]} + if index == 2: + # Only the exact original generated pin may move from the top + # face center to the front top edge highlighted by the chapter. + allowed_points.add((0, .5, 0)) + if (annotation is None or annotation.title != title + or annotation.object_model_id != model.id or annotation.video_id != video.id + or (annotation.point_x,annotation.point_y,annotation.point_z) not in allowed_points + or (annotation.start_ms,annotation.end_ms) != expected_ranges[0] + or [(p.get('start_ms'),p.get('end_ms')) for p in annotation.playlist_json or []] != expected_ranges + or [p.get('clip_id') for p in annotation.playlist_json] != [str(IDS[f'clip{i}']) for i in expected_clips]): + raise SystemExit('Seeded annotation geometry or playlist changed; use ordinary replacement and review.') + # Generate all files before the atomic database switch. Old media remains + # available for an operator rollback and the preservation source stays intact. + directory = Path(settings.media_root) / settings.transcode_output_subdir + directory.mkdir(parents=True, exist_ok=True) + token = uuid4().hex + digest = hashlib.sha256(source.read_bytes()).hexdigest() + final = directory / f"{IDS['video']}.{digest}.{token}.mp4" + generated = [final] + try: + shutil.copyfile(source, final) + for clip in clips: + derived = directory / f'clip-{clip.id}-{token}.mp4' + generated.extend([derived, derived.with_suffix('.jpg')]) + generate_clip(final, derived, clip.start_ms, clip.end_ms) + clip.output_mp4_path = str(derived) + clip.transcript_excerpt = TEXT[clip.start_ms // 4000] + video.playback_storage_key = final.name + video.playback_sha256_checksum = digest + video.playback_file_size_bytes = final.stat().st_size + if video.title in (LEGACY_VIDEO_TITLE, CURRENT_VIDEO_TITLE): + video.title = CURRENT_VIDEO_TITLE + if transcript.title in (LEGACY_TRANSCRIPT_TITLE, CURRENT_TRANSCRIPT_TITLE): + transcript.title = CURRENT_TRANSCRIPT_TITLE + transcript.raw_text = 'WEBVTT\n\n' + '\n\n'.join( + f'00:00:{i*4:02d}.000 --> 00:00:{(i+1)*4:02d}.000\n{text}' for i,text in enumerate(TEXT)) + '\n' + for segment, text in zip(segments, TEXT): + segment.text = text + comparison = next(a for a in annotations if a.id == IDS['annotation3']) + comparison.playlist_json = [dict(entry, label=label) for entry,label in zip( + comparison.playlist_json, ('Front face / square', 'Compare view / circle'))] + top_edge = next(a for a in annotations if a.id == IDS['annotation2']) + top_edge.point_x, top_edge.point_y, top_edge.point_z = ANNOTATION_POINTS[1] + db.commit() + except BaseException: + for path in generated: + path.unlink(missing_ok=True) + raise + print('Synthetic chapters refreshed; original Top edge pin aligned to the front top edge. ' + 'IDs, model geometry, timing, review and publication states preserved.') def replace(model: Path | None, video: Path | None) -> None: @@ -78,5 +203,11 @@ def replace(model: Path | None, video: Path | None) -> None: parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--model',type=Path) parser.add_argument('--video',type=Path) + parser.add_argument('--synthetic-demo',type=Path,help='Refresh original seeded content from a generated demo directory, preserving review/publication states.') arguments=parser.parse_args() - replace(arguments.model,arguments.video) + if arguments.synthetic_demo: + if arguments.model or arguments.video: + parser.error('--synthetic-demo is independent of --model and --video.') + refresh_synthetic(arguments.synthetic_demo) + else: + replace(arguments.model,arguments.video) diff --git a/apps/api/app/scripts/seed_demo.py b/apps/api/app/scripts/seed_demo.py index d1ee0ac..a8a1649 100644 --- a/apps/api/app/scripts/seed_demo.py +++ b/apps/api/app/scripts/seed_demo.py @@ -12,12 +12,12 @@ from app.db.session import SessionLocal from app.models.entities import User, Project, Object, ObjectModel, ObjectModelAnnotation, Video, VideoStatus, Transcript, TranscriptFormat, TranscriptSource, Segment, Clip, ClipStatus from app.scripts.generate_demo_assets import generate_assets, generate_clip +from app.scripts.demo_storyboard import CHAPTERS IDS = {name: UUID(f'00000000-0000-4000-8000-{index:012d}') for index,name in enumerate( ('user','project','object','model','video','transcript','segment1','segment2','segment3','annotation1','annotation2','annotation3','clip1','clip2','clip3'),1)} -TEXT = ['The blue section introduces the cube and its front face.', - 'The amber section identifies the top edge of the cube.', - 'The purple section returns to the cube for comparison.'] +TEXT = [chapter['text'] for chapter in CHAPTERS] +ANNOTATION_POINTS = ((0, 0, .5), (0, .5, .5), (.5, 0, 0)) def seed() -> None: @@ -48,12 +48,12 @@ def seed() -> None: db.flush() db.add(Object(id=IDS['object'],project_id=IDS['project'],name='Demo cube',description='A generated cube with timed video annotations.',website_object_id='demo-cube',is_published=True,is_embed_ready=True,metadata_json={'public_standfirst':'Explore three annotations on an original geometric sample.'})) db.flush() - db.add(Video(id=IDS['video'],project_id=IDS['project'],object_id=IDS['object'],stable_video_id='demo-colors',title='Three color sections',original_filename='colors.mp4',source_path=str(assets/'colors.mp4'),sha256_checksum=video_hash,playback_sha256_checksum=video_hash,playback_file_size_bytes=len(video_bytes),duration_ms=12000,status=VideoStatus.READY,is_published=True)) + db.add(Video(id=IDS['video'],project_id=IDS['project'],object_id=IDS['object'],stable_video_id='demo-colors',title='Three cube views',original_filename='colors.mp4',source_path=str(assets/'colors.mp4'),sha256_checksum=video_hash,playback_sha256_checksum=video_hash,playback_file_size_bytes=len(video_bytes),duration_ms=12000,status=VideoStatus.READY,is_published=True)) db.add(ObjectModel(id=IDS['model'],object_id=IDS['object'],storage_path=str(assets/'cube.glb'),original_filename='cube.glb',mime_type='model/gltf-binary',sha256_checksum=hashlib.sha256((assets/'cube.glb').read_bytes()).hexdigest(),file_size_bytes=(assets/'cube.glb').stat().st_size,revision_number=1,default_camera_json={'position':[2,1.5,2],'target':[0,0,0]},public_poster_path=str(assets/'poster.png'),is_published=True,uploaded_by=IDS['user'])) db.flush() vtt='WEBVTT\n\n'+'\n\n'.join(f'00:00:{i*4:02d}.000 --> 00:00:{(i+1)*4:02d}.000\n{text}' for i,text in enumerate(TEXT))+'\n' (assets/'transcript.vtt').write_text(vtt) - db.add(Transcript(id=IDS['transcript'],video_id=IDS['video'],title='Color demonstration transcript',source=TranscriptSource.MANUAL,format=TranscriptFormat.VTT,language='en',raw_text=vtt,is_published=True)) + db.add(Transcript(id=IDS['transcript'],video_id=IDS['video'],title='Cube view demonstration transcript',source=TranscriptSource.MANUAL,format=TranscriptFormat.VTT,language='en',raw_text=vtt,is_published=True)) db.flush() for i,text in enumerate(TEXT,1): db.add(Segment(id=IDS[f'segment{i}'],transcript_id=IDS['transcript'],position=i-1,start_ms=(i-1)*4000,end_ms=i*4000,text=text)) @@ -69,14 +69,13 @@ def seed() -> None: status=ClipStatus.COMPLETE, )) db.flush() - points=[(0,0,.5),(0,.5,0),(.5,0,0)] for i,title in enumerate(('Front face','Top edge','Compare two sections'),1): start,end=((i-1)*4000,i*4000) playlist=[{'video_id':str(IDS['video']),'clip_id':str(IDS[f'clip{i}']),'transcript_segment_id':str(IDS[f'segment{i}']),'start_ms':start,'end_ms':end,'label':title}] if i==3: start,end=0,4000 - playlist=[{'video_id':str(IDS['video']),'clip_id':str(IDS['clip1']),'transcript_segment_id':str(IDS['segment1']),'start_ms':0,'end_ms':4000,'label':'Blue section'}, {'video_id':str(IDS['video']),'clip_id':str(IDS['clip3']),'transcript_segment_id':str(IDS['segment3']),'start_ms':8000,'end_ms':12000,'label':'Purple section'}] - x,y,z=points[i-1] + playlist=[{'video_id':str(IDS['video']),'clip_id':str(IDS['clip1']),'transcript_segment_id':str(IDS['segment1']),'start_ms':0,'end_ms':4000,'label':'Front face / square'}, {'video_id':str(IDS['video']),'clip_id':str(IDS['clip3']),'transcript_segment_id':str(IDS['segment3']),'start_ms':8000,'end_ms':12000,'label':'Compare view / circle'}] + x,y,z=ANNOTATION_POINTS[i-1] db.add(ObjectModelAnnotation(id=IDS[f'annotation{i}'],object_model_id=IDS['model'],object_id=IDS['object'],video_id=IDS['video'],clip_id=IDS[f'clip{1 if i == 3 else i}'],transcript_segment_id=IDS[f'segment{1 if i == 3 else i}'],website_annotation_id=f'demo-annotation-{i}',title=title,description='Synthetic demonstration annotation.',point_x=x,point_y=y,point_z=z,camera_json={'position':[2,1.5,2],'target':[0,0,0]},playlist_json=playlist,start_ms=start,end_ms=end,is_published=True,model_revision_created_against=1,created_by=IDS['user'])) db.commit() print('Seeded demo-cube with three annotations, including a two-clip sequence.') diff --git a/apps/api/constraints.txt b/apps/api/constraints.txt index cd3d817..0e8d39c 100644 --- a/apps/api/constraints.txt +++ b/apps/api/constraints.txt @@ -48,7 +48,7 @@ pydantic==2.13.5 pydantic-settings==2.10.1 pydantic_core==2.46.5 Pygments==2.21.0 -PyJWT==2.13.0 +PyJWT==2.15.0 pytest==9.0.3 python-dateutil==2.9.0.post0 python-dotenv==1.2.2 diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 2677894..de52731 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -9,7 +9,7 @@ email-validator==2.2.0 redis==6.4.0 rq==2.6.0 passlib[bcrypt]==1.7.4 -PyJWT[crypto]==2.13.0 +PyJWT[crypto]==2.15.0 python-multipart==0.0.31 pgvector==0.4.1 python-dotenv==1.2.2 diff --git a/apps/api/tests/test_authenticated_playback_identity.py b/apps/api/tests/test_authenticated_playback_identity.py new file mode 100644 index 0000000..a77d618 --- /dev/null +++ b/apps/api/tests/test_authenticated_playback_identity.py @@ -0,0 +1,34 @@ +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints import videos +from app.models.entities import VideoStatus + + +class Session: + def __init__(self, video): + self.video = video + + def execute(self, statement): + return SimpleNamespace(scalar_one_or_none=lambda: self.video) + + +def test_authoring_stream_uses_the_current_persisted_rendition(tmp_path, monkeypatch): + media = tmp_path / 'current.mp4' + media.write_bytes(b'current approved rendition') + video = SimpleNamespace(id=uuid4(), status=VideoStatus.READY, original_filename='cube.mp4') + monkeypatch.setattr(videos, 'video_playback_path', lambda value: media if value is video else None) + monkeypatch.setattr(videos, 'transcode_output_path', lambda value: tmp_path / 'old.mp4') + response = videos.stream_video(video.id, Session(video), SimpleNamespace(id=uuid4())) + assert response.path == media + + +def test_authoring_stream_rejects_invalid_persisted_identity(monkeypatch): + video = SimpleNamespace(id=uuid4(), status=VideoStatus.READY) + monkeypatch.setattr(videos, 'video_playback_path', lambda value: None) + with pytest.raises(HTTPException) as error: + videos.stream_video(video.id, Session(video), SimpleNamespace(id=uuid4())) + assert error.value.status_code == 404 diff --git a/apps/api/tests/test_demo_storyboard.py b/apps/api/tests/test_demo_storyboard.py new file mode 100644 index 0000000..27135ed --- /dev/null +++ b/apps/api/tests/test_demo_storyboard.py @@ -0,0 +1,85 @@ +"""Check real encoded media and the preservation contract for generated refreshes.""" +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + +import pytest + +from app.scripts.demo_storyboard import BACKGROUND, CHAPTERS, FPS, WHITE, render_frame +from app.scripts.generate_demo_assets import generate_assets, generate_clip +from app.scripts.replace_demo_media import validate_synthetic_assets + + +@pytest.fixture(scope='module') +def assets(tmp_path_factory): + if not shutil.which('ffmpeg') or not shutil.which('ffprobe'): + pytest.skip('FFmpeg and ffprobe are required for the demo media integration test.') + destination = tmp_path_factory.mktemp('demo-storyboard') + generate_assets(destination) + return destination + + +def test_storyboard_changes_inside_and_between_chapters(): + assert render_frame(0) != render_frame(FPS) + assert len({hashlib.sha256(render_frame(i * 4 * FPS)).digest() for i in range(3)}) == 3 + assert [(c['start_ms'],c['end_ms']) for c in CHAPTERS] == [(0,4000),(4000,8000),(8000,12000)] + assert len({c['shape'] for c in CHAPTERS}) == 3 + with pytest.raises(ValueError): + render_frame(12 * FPS) + + +def test_text_and_feature_halo_have_strong_contrast(): + def luminance(color): + normalized = [v / 255 for v in color] + linear = [v / 12.92 if v <= .04045 else ((v + .055) / 1.055) ** 2.4 for v in normalized] + return sum(v * weight for v,weight in zip(linear,(.2126,.7152,.0722))) + assert (luminance(WHITE)+.05)/(luminance(BACKGROUND)+.05) > 7 + # Every chapter's white feature is backed by a dark outline, including amber. + for frame, feature, halo in ((0,(722,216),(722,221)), + (4*FPS,(722,216),(722,221)), + (8*FPS,(842,185),(842,192))): + pixels = render_frame(frame) + for point, color in ((feature,WHITE),(halo,BACKGROUND)): + offset = (point[1]*960+point[0])*3 + assert pixels[offset:offset+3] == bytes(color) + + +def test_real_video_and_clip_boundaries(assets, tmp_path): + assert validate_synthetic_assets(assets) == assets / 'colors.mp4' + probe = subprocess.run(['ffprobe','-v','error','-show_entries','stream=nb_frames,pix_fmt', + '-of','json',str(assets/'colors.mp4')],capture_output=True,text=True,check=True) + assert json.loads(probe.stdout)['streams'] == [{'pix_fmt':'yuv420p','nb_frames':'288'}] + for index, chapter in enumerate(CHAPTERS,1): + clip = tmp_path / f'clip-{index}.mp4' + generate_clip(assets/'colors.mp4',clip,chapter['start_ms'],chapter['end_ms']) + probe = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration', + '-of','json',str(clip)],capture_output=True,text=True,check=True) + assert float(json.loads(probe.stdout)['format']['duration']) == 4 + assert clip.with_suffix('.jpg').stat().st_size > 100 + decoded = subprocess.run(['ffmpeg','-v','error','-i',str(clip),'-frames:v','1', + '-f','rawvideo','-pix_fmt','rgb24','pipe:1'],capture_output=True,check=True).stdout + expected = render_frame(chapter['start_ms'] * FPS // 1000) + # Absolute video time is burned into the original frames. A clip starting + # at 8s must show 00:08.0 even though its own playback clock starts at 0. + def clock_region(pixels): + return b''.join(pixels[(y*960+486)*3:(y*960+654)*3] for y in range(428,456)) + actual_clock, expected_clock = clock_region(decoded), clock_region(expected) + assert len(decoded) == 960*540*3 + assert sum(abs(a-b) for a,b in zip(actual_clock,expected_clock))/len(actual_clock) < 8 + subprocess.run(['ffmpeg','-v','error','-i',str(assets/'colors.mp4'),'-f','null','-'],check=True) + + +def test_refresh_rejects_modified_media_or_timeline(assets, tmp_path): + shutil.copyfile(assets/'colors.mp4',tmp_path/'colors.mp4') + manifest = json.loads((assets/'demo-manifest.json').read_text()) + manifest['chapters'][2]['start_ms'] = 7000 + (tmp_path/'demo-manifest.json').write_text(json.dumps(manifest)) + with pytest.raises(SystemExit,match='chapter manifest'): + validate_synthetic_assets(tmp_path) + shutil.copyfile(assets/'demo-manifest.json',tmp_path/'demo-manifest.json') + with (tmp_path/'colors.mp4').open('ab') as stream: + stream.write(b'changed') + with pytest.raises(SystemExit,match='checksum'): + validate_synthetic_assets(tmp_path) diff --git a/apps/api/tests/test_security.py b/apps/api/tests/test_security.py index 78e4d40..397d658 100644 --- a/apps/api/tests/test_security.py +++ b/apps/api/tests/test_security.py @@ -1,5 +1,8 @@ from __future__ import annotations +import base64 +from datetime import datetime, timedelta, timezone + import jwt import pytest @@ -47,3 +50,24 @@ def test_access_token_rejects_unknown_critical_header(monkeypatch: pytest.Monkey with pytest.raises(ValueError, match="Invalid access token"): security.decode_access_token(token) + + +def test_access_token_rejects_expired_claims(monkeypatch: pytest.MonkeyPatch) -> None: + secret = "synthetic-expiry-check-secret-at-least-32-bytes" + monkeypatch.setattr(security.settings, "jwt_algorithm", "HS256") + monkeypatch.setattr(security.settings, "jwt_secret_key", secret) + token = jwt.encode({"sub": "user-1", "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, + secret, algorithm="HS256") + + with pytest.raises(ValueError, match="Invalid access token"): + security.decode_access_token(token) + + +def test_access_token_normalizes_deeply_nested_header_errors(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(security.settings, "jwt_algorithm", "HS256") + monkeypatch.setattr(security.settings, "jwt_secret_key", "synthetic-malformed-header-secret-at-least-32-bytes") + nested = b'{"nested":' + b'[' * 2000 + b'0' + b']' * 2000 + b'}' + header = base64.urlsafe_b64encode(nested).rstrip(b'=').decode() + + with pytest.raises(ValueError, match="Invalid access token"): + security.decode_access_token(f"{header}.e30.invalid-signature") diff --git a/apps/api/tests/test_synthetic_demo_refresh.py b/apps/api/tests/test_synthetic_demo_refresh.py new file mode 100644 index 0000000..80ed342 --- /dev/null +++ b/apps/api/tests/test_synthetic_demo_refresh.py @@ -0,0 +1,284 @@ +"""Isolated regressions for the synthetic demo media refresh.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.models.entities import ( + AnnotationReviewStatus, + Clip, + Object, + ObjectModel, + ObjectModelAnnotation, + Segment, + Transcript, + User, + Video, +) +from app.scripts import replace_demo_media as refresh +from app.scripts.demo_storyboard import CHAPTERS +from app.scripts.seed_demo import ANNOTATION_POINTS, IDS, TEXT + + +class _Rows: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return self._rows + + def __iter__(self): + return iter(self._rows) + + +class _FakeSession: + """Small transactional stand-in; never opens the configured application DB.""" + + def __init__(self, rows, commit_error=None): + self.rows = rows + self.commit_error = commit_error + self.persisted = {id(row): dict(vars(row)) for group in rows.values() for row in group} + self.commit_calls = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + if exc_type is not None: + for group in self.rows.values(): + for row in group: + vars(row).clear() + vars(row).update(self.persisted[id(row)]) + return False + + def get(self, model, key): + return next((row for row in self.rows[model] if row.id == key), None) + + def execute(self, statement): + model = statement.column_descriptions[0]['entity'] + return _Rows(self.rows[model]) + + def commit(self): + self.commit_calls += 1 + if self.commit_error: + raise self.commit_error + self.persisted = {id(row): dict(vars(row)) for group in self.rows.values() for row in group} + + +def _row(model, **values): + return SimpleNamespace(id=values.pop('id'), **values) + + +@pytest.fixture +def refresh_case(tmp_path, monkeypatch): + assets = tmp_path / 'assets' + assets.mkdir() + source = assets / 'colors.mp4' + source.write_bytes(b'generated-video') + cube = assets / 'cube.glb' + cube.write_bytes(b'generated-cube') + manifest = {'cube_sha256': hashlib.sha256(cube.read_bytes()).hexdigest()} + (assets / 'demo-manifest.json').write_text(json.dumps(manifest)) + + old_video = tmp_path / 'existing-video.mp4' + old_video.write_bytes(b'original-video') + obj = _row(Object, id=IDS['object'], website_object_id='demo-cube', is_published=True) + user = _row(User, id=IDS['user'], email='demo@example.org') + model = _row(ObjectModel, id=IDS['model'], sha256_checksum=manifest['cube_sha256'], is_published=True) + video = _row(Video, id=IDS['video'], stable_video_id='demo-colors', duration_ms=12000, + title=refresh.LEGACY_VIDEO_TITLE, playback_storage_key=old_video.name, + playback_sha256_checksum='old-digest', playback_file_size_bytes=13, + sha256_checksum='seed-source-digest', source_path='seed/colors.mp4', + original_filename='colors.mp4', is_published=True) + transcript = _row(Transcript, id=IDS['transcript'], title=refresh.LEGACY_TRANSCRIPT_TITLE, + raw_text='original transcript', is_published=True) + segments = [] + clips = [] + for index in range(1, 4): + start, end = (index - 1) * 4000, index * 4000 + segments.append(_row(Segment, id=IDS[f'segment{index}'], transcript_id=IDS['transcript'], + position=index - 1, start_ms=start, end_ms=end, text=TEXT[index - 1])) + old_clip = tmp_path / f'old-clip-{index}.mp4' + old_clip.write_bytes(b'original-clip') + clips.append(_row(Clip, id=IDS[f'clip{index}'], video_id=IDS['video'], start_ms=start, + end_ms=end, output_mp4_path=str(old_clip), + transcript_excerpt=f'original excerpt {index}')) + annotations = [] + points = ((0, 0, .5), (0, .5, 0), (.5, 0, 0)) + for index, title in enumerate(('Front face', 'Top edge', 'Compare two sections'), 1): + ranges = [(0, 4000), (8000, 12000)] if index == 3 else [((index - 1) * 4000, index * 4000)] + playlist_clips = (1, 3) if index == 3 else (index,) + labels = ('Front face / square', 'Compare view / circle') if index == 3 else (title,) + playlist = [ + {'video_id': str(IDS['video']), 'clip_id': str(IDS[f'clip{clip_index}']), + 'transcript_segment_id': str(IDS[f'segment{clip_index}']), + 'start_ms': start, 'end_ms': end, 'label': label} + for clip_index, (start, end), label in zip(playlist_clips, ranges, labels) + ] + annotations.append(_row( + ObjectModelAnnotation, id=IDS[f'annotation{index}'], title=title, + object_model_id=IDS['model'], video_id=IDS['video'], + point_x=points[index - 1][0], point_y=points[index - 1][1], point_z=points[index - 1][2], + start_ms=ranges[0][0], end_ms=ranges[0][1], playlist_json=playlist, + review_status=AnnotationReviewStatus.ACTIVE, is_published=True, + )) + rows = {Object: [obj], User: [user], ObjectModel: [model], Video: [video], + Transcript: [transcript], Segment: segments, Clip: clips, + ObjectModelAnnotation: annotations} + session = _FakeSession(rows) + monkeypatch.setattr(refresh, 'SessionLocal', lambda: session) + monkeypatch.setattr(refresh.settings, 'semantic_env', 'development') + monkeypatch.setattr(refresh.settings, 'media_root', str(tmp_path / 'media')) + monkeypatch.setattr(refresh.settings, 'transcode_output_subdir', 'transcoded') + monkeypatch.setattr(refresh.settings, 'media_upload_max_bytes', 1_000_000) + monkeypatch.setattr(refresh, 'validate_synthetic_assets', lambda _: source) + + def generate_clip(video_path, output, start, end): + Path(output).write_bytes(f'{start}:{end}'.encode()) + Path(output).with_suffix('.jpg').write_bytes(b'poster') + + monkeypatch.setattr(refresh, 'generate_clip', generate_clip) + return SimpleNamespace(assets=assets, rows=rows, session=session, video=video, + transcript=transcript, segments=segments, clips=clips, + annotations=annotations, old_video=old_video) + + +@pytest.mark.parametrize('legacy', [True, False], ids=['legacy-seed', 'current-seed']) +def test_refresh_updates_only_generated_title_aliases_and_preserves_contract(refresh_case, legacy): + case = refresh_case + if not legacy: + case.video.title = refresh.CURRENT_VIDEO_TITLE + case.transcript.title = refresh.CURRENT_TRANSCRIPT_TITLE + else: + for segment, text in zip(case.segments, ( + 'The blue section introduces the cube and its front face.', + 'The amber section identifies the top edge of the cube.', + 'The purple section returns to the cube for comparison.', + )): + segment.text = text + + if not legacy: + case.annotations[1].point_z = .5 + + refresh.refresh_synthetic(case.assets) + first_paths = [clip.output_mp4_path for clip in case.clips] + first_video_key = case.video.playback_storage_key + refresh.refresh_synthetic(case.assets) + + assert [(a.point_x, a.point_y, a.point_z) for a in case.annotations] == list(ANNOTATION_POINTS) + + assert case.video.title == refresh.CURRENT_VIDEO_TITLE + assert case.transcript.title == refresh.CURRENT_TRANSCRIPT_TITLE + assert case.video.playback_storage_key != first_video_key + assert all(Path(path).is_file() for path in first_paths) + assert case.session.commit_calls == 2 + assert case.video.id == IDS['video'] and case.transcript.id == IDS['transcript'] + assert case.video.sha256_checksum == 'seed-source-digest' + assert case.video.source_path == 'seed/colors.mp4' and case.video.original_filename == 'colors.mp4' + assert case.video.is_published is True + assert case.transcript.is_published is True + assert case.rows[Object][0].is_published is True + assert case.rows[ObjectModel][0].is_published is True + assert [row.id for row in case.segments] == [IDS[f'segment{i}'] for i in range(1, 4)] + assert [(row.start_ms, row.end_ms) for row in case.segments] == [(0, 4000), (4000, 8000), (8000, 12000)] + assert [row.text for row in case.segments] == TEXT + assert [row.id for row in case.clips] == [IDS[f'clip{i}'] for i in range(1, 4)] + assert [(row.start_ms, row.end_ms) for row in case.clips] == [(0, 4000), (4000, 8000), (8000, 12000)] + assert [row.id for row in case.annotations] == [IDS[f'annotation{i}'] for i in range(1, 4)] + assert all(row.review_status == AnnotationReviewStatus.ACTIVE and row.is_published for row in case.annotations) + + +def test_refresh_preserves_custom_titles_independently(refresh_case): + case = refresh_case + case.video.title = ' My edited video title ' + case.transcript.title = refresh.LEGACY_TRANSCRIPT_TITLE + + refresh.refresh_synthetic(case.assets) + + assert case.video.title == ' My edited video title ' + assert case.transcript.title == refresh.CURRENT_TRANSCRIPT_TITLE + + case.video.title = refresh.LEGACY_VIDEO_TITLE + case.transcript.title = 'My edited transcript' + refresh.refresh_synthetic(case.assets) + assert case.video.title == refresh.CURRENT_VIDEO_TITLE + assert case.transcript.title == 'My edited transcript' + + +def test_refresh_guard_failure_creates_no_media(refresh_case): + case = refresh_case + case.segments[0].text = 'Personalized segment' + media_dir = Path(refresh.settings.media_root) / refresh.settings.transcode_output_subdir + + with pytest.raises(SystemExit, match='Transcript has been personalized'): + refresh.refresh_synthetic(case.assets) + + assert not media_dir.exists() or list(media_dir.iterdir()) == [] + assert case.session.commit_calls == 0 + + +def test_refresh_rejects_a_customized_top_edge_point(refresh_case): + case = refresh_case + case.annotations[1].point_z = .25 + case.session.persisted[id(case.annotations[1])]['point_z'] = .25 + + with pytest.raises(SystemExit, match='Seeded annotation geometry or playlist changed'): + refresh.refresh_synthetic(case.assets) + + assert case.session.commit_calls == 0 + assert case.annotations[1].point_z == .25 + + +def test_partial_clip_generation_cleans_new_files_and_keeps_old_media(refresh_case, monkeypatch): + case = refresh_case + old_paths = [clip.output_mp4_path for clip in case.clips] + old_key = case.video.playback_storage_key + calls = 0 + + def fail_after_partial_output(video_path, output, start, end): + nonlocal calls + calls += 1 + Path(output).write_bytes(b'partial clip') + Path(output).with_suffix('.jpg').write_bytes(b'partial poster') + if calls == 2: + raise RuntimeError('clip generation failed') + + monkeypatch.setattr(refresh, 'generate_clip', fail_after_partial_output) + media_dir = Path(refresh.settings.media_root) / refresh.settings.transcode_output_subdir + with pytest.raises(RuntimeError, match='clip generation failed'): + refresh.refresh_synthetic(case.assets) + + assert list(media_dir.iterdir()) == [] + assert case.video.playback_storage_key == old_key + assert [clip.output_mp4_path for clip in case.clips] == old_paths + assert all(Path(path).is_file() for path in old_paths) + assert case.old_video.is_file() + assert case.session.commit_calls == 0 + + +def test_database_commit_failure_cleans_new_files_and_rolls_back_references(refresh_case): + case = refresh_case + old_paths = [clip.output_mp4_path for clip in case.clips] + old_key = case.video.playback_storage_key + case.session.commit_error = RuntimeError('commit failed') + media_dir = Path(refresh.settings.media_root) / refresh.settings.transcode_output_subdir + + with pytest.raises(RuntimeError, match='commit failed'): + refresh.refresh_synthetic(case.assets) + + assert list(media_dir.iterdir()) == [] + assert case.video.playback_storage_key == old_key + assert case.video.title == refresh.LEGACY_VIDEO_TITLE + assert case.transcript.title == refresh.LEGACY_TRANSCRIPT_TITLE + assert case.annotations[1].point_z == 0 + assert [clip.output_mp4_path for clip in case.clips] == old_paths + assert all(Path(path).is_file() for path in old_paths) + assert case.old_video.is_file() + assert case.session.commit_calls == 1 diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 59fc412..22c9104 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -1,12 +1,13 @@ { "name": "loci-web", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loci-web", - "version": "0.1.0", + "version": "0.2.0", + "license": "Apache-2.0", "dependencies": { "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", @@ -23,8 +24,7 @@ }, "engines": { "node": ">=22 <23" - }, - "license": "Apache-2.0" + } }, "node_modules/@babel/code-frame": { "version": "7.29.7", @@ -1898,9 +1898,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" diff --git a/apps/web/package.json b/apps/web/package.json index a925c78..bd525ab 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,17 +1,19 @@ { "name": "loci-web", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "test": "node --test", - "test:browser": "node tests/browser/annotation-share-regression.mjs && node tests/browser/public-browse-poster-reveal.mjs", + "test:browser": "node tests/browser/annotation-share-regression.mjs && node tests/browser/public-browse-poster-reveal.mjs && node tests/browser/console-surface-regression.mjs", "test:browser:public-browse": "node tests/browser/public-browse-poster-reveal.mjs", "smoke:model-load-policy": "node scripts/modelLoadPolicySmoke.cjs", - "test:demo": "node tests/browser/demo-journey.mjs" + "test:demo": "node tests/browser/demo-journey.mjs && npm run test:demo:continuity", + "test:demo:continuity": "node tests/browser/viewer-camera-continuity.mjs", + "test:demo:authoring": "node tests/browser/demo-authoring-journey.mjs" }, "dependencies": { "@react-three/drei": "^9.122.0", @@ -29,7 +31,7 @@ }, "overrides": { "@babel/core": "7.29.7", - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", "fflate": "0.8.3", "nanoid": "3.3.18", "postcss": "8.5.26" diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index 1f7ac61..7ad686e 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -57,6 +57,9 @@ import ConfirmDialog from './components/ConfirmDialog' import SearchAutosuggest from './components/SearchAutosuggest' import PublicExperienceShell from './components/PublicExperienceShell' import { formatLociTitle } from './lib/seo' +import { StudioThemeToggle, useStudioSurfaceTheme } from './public/studioSurface' +import { hasAnnotationPoint } from './lib/annotationPoint' +import { reconcileTranscriptDraft } from './lib/transcriptDraft' const TOKEN_KEY = 'semantic.console.token' const SEARCH_PAGE_SIZE = 5 @@ -694,12 +697,34 @@ function defaultModelTransform(model = null) { export default function App() { const initialRouteState = useMemo(() => readConsoleRouteState(), []) const isPreviewRouteLocked = initialRouteState.previewRouteLocked + const isPrivateAuthoringSurface = !IS_PUBLIC_APP && !isPreviewRouteLocked + const { palette, toggle: toggleStudioTheme } = useStudioSurfaceTheme({ applyToDocument: isPrivateAuthoringSurface }) const [token, setToken] = useState(() => (IS_PUBLIC_APP ? '' : localStorage.getItem(TOKEN_KEY) || '')) const [mode, setMode] = useState(() => ((IS_PUBLIC_APP || isPreviewRouteLocked) ? 'publicPreview' : 'analysis')) const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [notice, setNotice] = useState('') + useEffect(() => { + if (!isPrivateAuthoringSurface) return undefined + const root = document.documentElement + root.classList.add('authoring-document-surface') + return () => { + root.classList.remove('authoring-document-surface') + } + }, [isPrivateAuthoringSurface]) + + useEffect(() => { + if (!isPrivateAuthoringSurface) return undefined + const root = document.documentElement + const previousTheme = root.dataset.authoringTheme + root.dataset.authoringTheme = palette + return () => { + if (previousTheme === undefined) delete root.dataset.authoringTheme + else root.dataset.authoringTheme = previousTheme + } + }, [isPrivateAuthoringSurface, palette]) + const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -727,6 +752,7 @@ export default function App() { const [viewerTranscript, setViewerTranscript] = useState(null) const [viewerSegments, setViewerSegments] = useState([]) const [viewerDraftText, setViewerDraftText] = useState('') + const [viewerDraftConflict, setViewerDraftConflict] = useState(false) const [viewerHistory, setViewerHistory] = useState({ entries: [], index: -1 }) const [viewerMediaUrl, setViewerMediaUrl] = useState('') const [viewerLoading, setViewerLoading] = useState(false) @@ -1163,8 +1189,31 @@ export default function App() { const modelFileInputRef = useRef(null) const previousModeRef = useRef(mode) const analysisSessionRef = useRef(null) + const viewerDraftSourceRef = useRef(null) + const viewerDraftTextRef = useRef('') const previousLiveDatabaseSyncStateRef = useRef(liveDatabaseSyncStatus.state) + function updateViewerDraftText(text) { + viewerDraftTextRef.current = text + setViewerDraftText(text) + } + + function refreshViewerDraft(transcript, { force = false } = {}) { + const result = reconcileTranscriptDraft({ + source: force ? null : viewerDraftSourceRef.current, + draftText: viewerDraftTextRef.current, + videoId: viewerVideoId, + token, + transcript + }) + if (result.action === 'replace') { + viewerDraftSourceRef.current = result.source + updateViewerDraftText(result.text) + initializeViewerHistory(result.text) + } + setViewerDraftConflict(result.action === 'conflict') + } + function initializeViewerHistory(nextText) { setViewerHistory({ entries: nextText ? [nextText] : [], index: nextText ? 0 : -1 }) } @@ -1642,7 +1691,7 @@ export default function App() { } try { - setSelectedModelAnnotationUrl(getVideoPlaybackUrl(token, annotationOverlayClipVideo.id)) + setSelectedModelAnnotationUrl(getVideoPlaybackUrl(token, annotationOverlayClipVideo.id, annotationOverlayClipVideo.updated_at)) } catch { setSelectedModelAnnotationUrl('') } @@ -1825,7 +1874,7 @@ export default function App() { setViewerMediaUrl('') try { if (activeVideo?.status === 'READY') { - const mediaUrl = await getVideoPlaybackUrl(token, viewerVideoId) + const mediaUrl = await getVideoPlaybackUrl(token, viewerVideoId, activeVideo?.updated_at) if (!cancelled) { setViewerMediaUrl(mediaUrl) } @@ -1839,8 +1888,7 @@ export default function App() { } setViewerTranscript(transcriptData.transcript) setViewerSegments(transcriptData.segments || []) - setViewerDraftText(transcriptData.transcript.raw_text) - initializeViewerHistory(transcriptData.transcript.raw_text) + refreshViewerDraft(transcriptData.transcript) setViewerCurrentMs((current) => ( current > 0 && analysisSessionRef.current?.videoId === viewerVideoId ? current : 0 )) @@ -1850,8 +1898,7 @@ export default function App() { if (activeVideo?.status === 'READY' && isTranscriptMissing) { setViewerTranscript(null) setViewerSegments([]) - setViewerDraftText('') - initializeViewerHistory('') + refreshViewerDraft(null) setNotice('Transcript is still finalizing. Retrying automatically...') setTimeout(() => { refreshData(token).catch(() => null) @@ -1860,8 +1907,7 @@ export default function App() { } setViewerTranscript(null) setViewerSegments([]) - setViewerDraftText('') - initializeViewerHistory('') + refreshViewerDraft(null) setViewerMediaUrl('') setError(err.message) } @@ -1877,7 +1923,7 @@ export default function App() { return () => { cancelled = true } - }, [token, viewerVideoId, activeVideo?.status]) + }, [token, mode, viewerVideoId, activeVideo?.status, activeVideo?.updated_at]) useEffect(() => { const previousMode = previousModeRef.current @@ -2686,7 +2732,7 @@ export default function App() { setError('Annotation start and end times must be valid, and end must be greater than start.') return } - if (!Number.isFinite(pointX) || !Number.isFinite(pointY) || !Number.isFinite(pointZ)) { + if (!hasAnnotationPoint(annotationEdit)) { setError('Click the model to capture a valid annotation point before saving.') return } @@ -3610,11 +3656,11 @@ export default function App() { if (viewerHistory.index < 1) return const previous = viewerHistory.entries[viewerHistory.index - 1] || '' setViewerHistory((prev) => ({ ...prev, index: prev.index - 1 })) - setViewerDraftText(previous) + updateViewerDraftText(previous) } async function saveViewerTranscript() { - if (!viewerVideoId || !viewerTranscript) return + if (!viewerVideoId || !viewerTranscript || viewerDraftConflict || viewerLoading) return setViewerSaving(true) setError('') try { @@ -3628,7 +3674,12 @@ export default function App() { setViewerTranscript(result.transcript) const refreshed = await getVideoTranscript(token, viewerVideoId) setViewerSegments(refreshed.segments || []) - setViewerDraftText(refreshed.transcript.raw_text) + setViewerTranscript(refreshed.transcript) + viewerDraftSourceRef.current = { + videoId: viewerVideoId, token, transcriptId: refreshed.transcript.id, rawText: refreshed.transcript.raw_text + } + updateViewerDraftText(refreshed.transcript.raw_text) + setViewerDraftConflict(false) pushUndoState(refreshed.transcript.raw_text) setNotice('Transcript saved and reindexed for analysis.') } catch (err) { @@ -3752,7 +3803,10 @@ export default function App() { if (!token && !IS_PUBLIC_APP) { return ( -
+
@@ -3760,6 +3814,12 @@ export default function App() {

Evidence, located.

+ {isPrivateAuthoringSurface ? ( +
+ Private authoring + +
+ ) : null}

{isPreviewRouteLocked ? 'Loci Preview' : 'Loci Console'}

Sign in

@@ -3856,7 +3916,10 @@ export default function App() { ) return ( -

+
{mediaUploading ? (
@@ -3908,6 +3971,7 @@ export default function App() { )}
+ {isPrivateAuthoringSurface ? Private authoring : null}

{isPreviewRouteLocked ? 'Loci Preview' : 'Loci Console'}

{consoleShellTitle}

{isPreviewRouteLocked ?

Authenticated published-object preview only.

: null} @@ -3915,6 +3979,7 @@ export default function App() {
{!IS_PUBLIC_APP ? (
+ {!isPreviewRouteLocked ? : null} {!isPreviewRouteLocked ? ( <> +
+ ) : null}
-
@@ -5497,8 +5575,8 @@ export default function App() { ) : null}
-
-
-
-

Publication

-

Promote the current object package from private authoring into the public-facing preview.

-
-
- -

- Pushes every currently published package to the live public site using the one-way semantic_public promotion flow. -

- -
-

- Object: {activeModelObject ? (activeModelObject.is_published ? 'Published' : 'Private') : 'No object selected'} -

-

- 3D model: {modelDetail ? (modelDetail.is_published ? 'Published' : 'Private') : 'No model uploaded'} -

-

- Video: {currentModelPublicationVideo ? (currentModelPublicationVideo.is_published ? 'Published' : 'Private') : 'No video selected'} -

-

- Transcript: {currentModelPublicationTranscript ? (currentModelPublicationTranscript.is_published ? 'Published with video' : 'Private with video') : 'No transcript yet'} -

-

- Package: {packageStatusLabel} -

-

- Published annotations: {publishedModelAnnotationCount} / {modelAnnotations.length} -

- {!packageOpenNow && publicationBlockers.length > 0 ? ( -
    - {publicationBlockers.map((blocker) => ( -
  • {blocker.label}
  • - ))} -
- ) : null} -
- -
- - -
- -
- - - - - - - - -
- - {/* - * Slice E: Notify followers toggle. - * - * Per-package opt-in. Default OFF (founder direction - * 2026-04-27). When ON, the next publish + sync of - * this package fires a digest email via the public - * notify-me subscriber list (apps/api/app/services/ - * notify_digest_worker.py — Slice E-4). When OFF, - * publish + sync proceed as before with no digest. - * - * The trigger is intentionally NOT auto-fired on - * is_published transition — the digest must wait - * until the public projection actually contains the - * synced content (otherwise subscribers click into - * a 404). Slice E-4's admin endpoint is the - * post-sync trigger. - */} - - -
-
- {liveDatabaseSyncStatus.stage_label || 'Live Database sync status'} - {liveDatabaseSyncStateLabel(liveDatabaseSyncStatus.state)} · {liveDatabaseSyncProgress}% -
- -

{liveDatabaseSyncStatus.detail || 'Push every currently published package to the live public site.'}

- {liveDatabaseSyncCountsAvailable ? ( -
- {liveDatabaseSyncStatus.object_count} objects - {liveDatabaseSyncStatus.video_count} videos - {liveDatabaseSyncStatus.annotation_count} annotations - {liveDatabaseSyncStatus.media_file_count} media files -
- ) : null} - {liveDatabaseSyncStatus.finished_at ? ( -

Last finished: {formatDateTime(liveDatabaseSyncStatus.finished_at)}

- ) : liveDatabaseSyncStatus.started_at ? ( -

Started: {formatDateTime(liveDatabaseSyncStatus.started_at)}

- ) : null} - {liveDatabaseSyncStatus.error ?

Last error: {liveDatabaseSyncStatus.error}

: null} -
- -
- - -
-
-
-

Annotation Editor

@@ -5838,15 +5721,18 @@ export default function App() { ) : null} setAnnotationEdit((prev) => ({ ...prev, title: e.target.value }))} />