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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions PYTHON_THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -6651,7 +6651,7 @@ Originally written and maintained by `Jeff Lindsay <https://github.com/progrium>
A full list of contributors can be found on GitHub’s `overview <https://github.com/jpadilla/pyjwt/graphs/contributors>`_.
```

## 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)
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions apps/api/app/api/v1/endpoints/videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
171 changes: 171 additions & 0 deletions apps/api/app/scripts/demo_storyboard.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading